Node.js / TypeScript
Use Convoy’s REST API from Node.js and TypeScript applications. This guide provides a typed client class, webhook receiver patterns, and integration examples for Express, Next.js, and serverless functions.
Testing tip — Build and verify this integration end-to-end with convoy-mock first. It’s a free, synthetic model that returns a callback in ~60 seconds and is never billed. Once your wiring works, swap "model": "convoy-mock" for a production model like claude-3-haiku.
Quick Start
Submit a prompt in under 10 lines:
const response = await fetch('https://api.cnvy.ai/cargo/load', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.CONVOY_API_KEY!,
},
body: JSON.stringify({
params: {
model: 'claude-3-haiku',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Explain quantum computing in 3 sentences.' }],
},
callback_url: 'https://your-server.com/webhook/convoy',
}),
});
const { cargo_id } = await response.json();
console.log(`Submitted! Cargo ID: ${cargo_id}`);Convoy processes requests in cost-efficient batches. Results arrive at your callback_url within minutes to hours, depending on queue volume and batch size (up to 24-hour SLA). For the callback receiver pattern, see the Webhooks guide.
Installation
No SDK package needed — just use the built-in fetch API (Node.js 18+) or install a lightweight HTTP client:
# Node.js 18+ has built-in fetch — no dependencies needed!
# Or if you prefer axios:
npm install axios
# For TypeScript projects:
npm install -D typescript @types/nodeTyped Convoy Client
Here’s a fully-typed TypeScript client for the Convoy API:
// convoy-client.ts
export interface ConvoyMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export interface ConvoyLoadParams {
model: string;
max_tokens: number;
messages: ConvoyMessage[];
system?: string;
temperature?: number;
top_p?: number;
stop_sequences?: string[];
}
export interface ConvoyLoadRequest {
params: ConvoyLoadParams;
callback_url?: string;
}
export interface ConvoyLoadResponse {
cargo_id: string;
status: string;
message: string;
}
export interface ConvoyUsage {
input_tokens: number;
output_tokens: number;
}
export interface ConvoyContent {
type: 'text';
text: string;
}
export interface ConvoyResponse {
id: string;
type: string;
role: string;
content: ConvoyContent[];
model: string;
stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence';
usage: ConvoyUsage;
}
export interface ConvoyTrackingResult {
cargo_id: string;
status: 'PENDING' | 'BATCHED' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
response?: ConvoyResponse;
error?: string;
}
export interface ConvoyCallbackPayload {
cargo_id: string;
success: boolean;
response: ConvoyResponse | null;
error: string | null;
}
export interface ConvoyModel {
id: string;
description?: string;
}
export interface ConvoyClientOptions {
apiKey: string;
baseUrl?: string;
timeout?: number;
}
export class ConvoyClient {
private apiKey: string;
private baseUrl: string;
private timeout: number;
constructor(options: ConvoyClientOptions) {
this.apiKey = options.apiKey;
this.baseUrl = (options.baseUrl || 'https://api.cnvy.ai').replace(/\/$/, '');
this.timeout = options.timeout || 30_000;
}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new ConvoyError(response.status, error.detail || 'Request failed');
}
return response.json() as Promise<T>;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Submit a prompt for batch processing.
*/
async load(request: ConvoyLoadRequest): Promise<ConvoyLoadResponse> {
return this.request<ConvoyLoadResponse>('/cargo/load', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Check the status of a submitted cargo request.
*/
async track(cargoId: string): Promise<ConvoyTrackingResult> {
return this.request<ConvoyTrackingResult>(`/cargo/${cargoId}/tracking`);
}
/**
* Poll until the cargo is complete or failed.
*/
async waitForResult(
cargoId: string,
options: { pollInterval?: number; timeout?: number } = {}
): Promise<ConvoyTrackingResult> {
const { pollInterval = 10_000, timeout = 1_800_000 } = options;
const start = Date.now();
while (Date.now() - start < timeout) {
const result = await this.track(cargoId);
if (result.status === 'COMPLETED' || result.status === 'FAILED') {
return result;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
throw new ConvoyError(408, `Cargo ${cargoId} did not complete within ${timeout}ms`);
}
/**
* List available models.
*/
async models(): Promise<ConvoyModel[]> {
return this.request<ConvoyModel[]>('/models');
}
}
export class ConvoyError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = 'ConvoyError';
}
}
// Helper to extract text from a response
export function extractText(response: ConvoyResponse): string {
return response.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n');
}Usage Examples
Basic: Submit and Poll
import { ConvoyClient, extractText } from './convoy-client';
const client = new ConvoyClient({
apiKey: process.env.CONVOY_API_KEY!,
});
// Submit a prompt
const { cargo_id } = await client.load({
params: {
model: 'claude-3-haiku',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Write a haiku about TypeScript.' }],
},
});
console.log(`Submitted: ${cargo_id}`);
// Wait for the result
const result = await client.waitForResult(cargo_id);
if (result.status === 'COMPLETED' && result.response) {
console.log(extractText(result.response));
} else {
console.error(`Failed: ${result.error}`);
}With Callback URL
const { cargo_id } = await client.load({
params: {
model: 'claude-3-sonnet',
max_tokens: 2048,
messages: [{ role: 'user', content: 'Write a product launch email.' }],
system: 'You are a marketing copywriter. Be engaging and concise.',
temperature: 0.7,
},
callback_url: 'https://your-server.com/webhook/convoy',
});
console.log(`Submitted: ${cargo_id} — result will arrive at your webhook`);Batch Submit
import { ConvoyClient, extractText } from './convoy-client';
const client = new ConvoyClient({ apiKey: process.env.CONVOY_API_KEY! });
const prompts = [
'Write a tweet about our new AI feature',
'Generate a subject line for our newsletter',
'Create a one-paragraph product description',
'Write a customer testimonial request email',
'Draft a changelog entry for version 2.0',
];
// Submit all prompts
const cargoIds = await Promise.all(
prompts.map((prompt) =>
client.load({
params: {
model: 'claude-3-haiku',
max_tokens: 512,
messages: [{ role: 'user', content: prompt }],
system: 'You are a marketing copywriter. Be concise.',
},
})
)
);
console.log(`Submitted ${cargoIds.length} prompts`);
// Wait for all results
const results = await Promise.all(
cargoIds.map(({ cargo_id }) => client.waitForResult(cargo_id))
);
for (const result of results) {
if (result.status === 'COMPLETED' && result.response) {
console.log(`✅ ${extractText(result.response).slice(0, 80)}...`);
} else {
console.log(`❌ ${result.error}`);
}
}Convoy allows 60 requests per minute and 500 per hour per project. When submitting many prompts concurrently, add rate limiting or use p-limit to control concurrency.
Webhook Receiver
Express
import express from 'express';
import type { ConvoyCallbackPayload } from './convoy-client';
const app = express();
app.use(express.json());
app.post('/webhook/convoy', (req, res) => {
const payload = req.body as ConvoyCallbackPayload;
const { cargo_id, success, response, error } = payload;
if (success && response) {
const text = response.content[0].text;
console.log(`[${cargo_id}] Result: ${text.slice(0, 100)}...`);
// Process the result — save to DB, notify user, etc.
} else {
console.error(`[${cargo_id}] Failed: ${error}`);
}
// Always respond 200 to acknowledge
res.json({ received: true });
});
app.listen(3000, () => console.log('Webhook receiver on :3000'));Next.js App Router
// app/api/webhook/convoy/route.ts
import { NextRequest, NextResponse } from 'next/server';
import type { ConvoyCallbackPayload } from '@/lib/convoy-client';
export async function POST(request: NextRequest) {
const payload: ConvoyCallbackPayload = await request.json();
const { cargo_id, success, response, error } = payload;
if (success && response) {
const text = response.content[0].text;
// Save to database
await db.results.create({
data: {
cargoId: cargo_id,
text,
model: response.model,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
},
});
} else {
console.error(`[${cargo_id}] Error: ${error}`);
// Handle failure — retry, alert, etc.
}
return NextResponse.json({ received: true });
}Next.js Pages Router
// pages/api/webhook/convoy.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import type { ConvoyCallbackPayload } from '@/lib/convoy-client';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const payload = req.body as ConvoyCallbackPayload;
if (payload.success && payload.response) {
const text = payload.response.content[0].text;
// Process result...
}
res.status(200).json({ received: true });
}AWS Lambda (Serverless)
// handler.ts
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import type { ConvoyCallbackPayload } from './convoy-client';
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
const payload: ConvoyCallbackPayload = JSON.parse(event.body || '{}');
const { cargo_id, success, response, error } = payload;
if (success && response) {
const text = response.content[0].text;
// Save to DynamoDB, S3, etc.
console.log(`[${cargo_id}] Processed ${response.usage.output_tokens} tokens`);
} else {
console.error(`[${cargo_id}] Failed: ${error}`);
}
return {
statusCode: 200,
body: JSON.stringify({ received: true }),
};
}Error Handling
import { ConvoyClient, ConvoyError } from './convoy-client';
const client = new ConvoyClient({ apiKey: process.env.CONVOY_API_KEY! });
try {
const { cargo_id } = await client.load({
params: {
model: 'claude-3-haiku',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello!' }],
},
});
} catch (err) {
if (err instanceof ConvoyError) {
switch (err.statusCode) {
case 401:
console.error('Invalid API key');
break;
case 422:
console.error(`Validation error: ${err.message}`);
break;
case 429:
console.error('Rate limited — retry after delay');
break;
default:
console.error(`API error ${err.statusCode}: ${err.message}`);
}
} else {
console.error('Network error:', err);
}
}Retry with Exponential Backoff
async function loadWithRetry(
client: ConvoyClient,
request: ConvoyLoadRequest,
maxRetries = 3
): Promise<ConvoyLoadResponse> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await client.load(request);
} catch (err) {
if (err instanceof ConvoyError) {
const isRetryable = err.statusCode === 429 || err.statusCode >= 500;
if (isRetryable && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.log(`Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, delay));
continue;
}
}
throw err;
}
}
throw new Error('Max retries exceeded');
}Integration Patterns
Pattern 1: Express API with Background Processing
import express from 'express';
import { ConvoyClient, ConvoyCallbackPayload, extractText } from './convoy-client';
const app = express();
app.use(express.json());
const client = new ConvoyClient({ apiKey: process.env.CONVOY_API_KEY! });
// In-memory store (use Redis/DB in production)
const jobs = new Map<string, { userId: string; prompt: string; result?: string }>();
// User submits a prompt
app.post('/api/generate', async (req, res) => {
const { prompt, model = 'claude-3-haiku' } = req.body;
const userId = req.headers['x-user-id'] as string;
const { cargo_id } = await client.load({
params: {
model,
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }],
},
callback_url: `${process.env.PUBLIC_URL}/webhook/convoy`,
});
jobs.set(cargo_id, { userId, prompt });
res.json({ cargo_id, status: 'submitted' });
});
// Check job status
app.get('/api/generate/:cargoId', (req, res) => {
const job = jobs.get(req.params.cargoId);
if (!job) return res.status(404).json({ error: 'Not found' });
res.json({
cargo_id: req.params.cargoId,
status: job.result ? 'completed' : 'processing',
result: job.result,
});
});
// Receive Convoy callback
app.post('/webhook/convoy', (req, res) => {
const payload = req.body as ConvoyCallbackPayload;
const job = jobs.get(payload.cargo_id);
if (job && payload.success && payload.response) {
job.result = extractText(payload.response);
// Notify user via WebSocket, email, etc.
}
res.json({ received: true });
});
app.listen(3000);Pattern 2: Queue-Based Processing with Bull
import Queue from 'bull';
import { ConvoyClient } from './convoy-client';
const client = new ConvoyClient({ apiKey: process.env.CONVOY_API_KEY! });
// Create a queue for prompt submissions
const promptQueue = new Queue('convoy-prompts', process.env.REDIS_URL!);
// Process jobs with rate limiting
promptQueue.process(5, async (job) => {
const { prompt, model, callbackUrl, metadata } = job.data;
const { cargo_id } = await client.load({
params: {
model: model || 'claude-3-haiku',
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }],
},
callback_url: callbackUrl,
});
return { cargo_id, metadata };
});
// Add jobs to the queue
export async function submitPrompt(prompt: string, metadata: Record<string, unknown>) {
return promptQueue.add(
{ prompt, model: 'claude-3-sonnet', callbackUrl: process.env.CALLBACK_URL, metadata },
{ attempts: 3, backoff: { type: 'exponential', delay: 2000 } }
);
}Pattern 3: CLI Tool
#!/usr/bin/env npx tsx
// convoy-cli.ts
import { ConvoyClient, extractText } from './convoy-client';
const [, , ...args] = process.argv;
const prompt = args.join(' ');
if (!prompt) {
console.error('Usage: npx tsx convoy-cli.ts <prompt>');
process.exit(1);
}
const client = new ConvoyClient({
apiKey: process.env.CONVOY_API_KEY!,
baseUrl: process.env.CONVOY_BASE_URL,
});
console.error(`Submitting to Convoy...`);
const { cargo_id } = await client.load({
params: {
model: process.env.CONVOY_MODEL || 'claude-3-haiku',
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }],
},
});
console.error(`Cargo ID: ${cargo_id}`);
console.error(`Waiting for result...`);
const result = await client.waitForResult(cargo_id, { pollInterval: 5000 });
if (result.status === 'COMPLETED' && result.response) {
console.log(extractText(result.response));
} else {
console.error(`Error: ${result.error}`);
process.exit(1);
}Usage:
export CONVOY_API_KEY=convoy_sk_your_key_here
npx tsx convoy-cli.ts "Write a haiku about containers"
npx tsx convoy-cli.ts "Generate a JSON schema for a user profile" | jq .Environment Configuration
// config.ts
import { ConvoyClient } from './convoy-client';
if (!process.env.CONVOY_API_KEY) {
throw new Error('CONVOY_API_KEY environment variable is required');
}
export const convoy = new ConvoyClient({
apiKey: process.env.CONVOY_API_KEY,
baseUrl: process.env.CONVOY_BASE_URL || 'https://api.cnvy.ai',
timeout: Number(process.env.CONVOY_TIMEOUT) || 30_000,
});.env file:
CONVOY_API_KEY=convoy_sk_your_key_here
CONVOY_BASE_URL=https://api.cnvy.ai
CONVOY_TIMEOUT=30000Never commit API keys to version control. Use .env files locally and environment variables or secrets managers in production.
Rate Limiting with p-limit
Control concurrency when submitting many prompts:
import pLimit from 'p-limit';
import { ConvoyClient } from './convoy-client';
const client = new ConvoyClient({ apiKey: process.env.CONVOY_API_KEY! });
const limit = pLimit(10); // Max 10 concurrent requests
const prompts = Array.from({ length: 100 }, (_, i) => `Write tip #${i + 1} for Node.js developers`);
const results = await Promise.all(
prompts.map((prompt) =>
limit(() =>
client.load({
params: {
model: 'claude-3-haiku',
max_tokens: 256,
messages: [{ role: 'user', content: prompt }],
},
callback_url: process.env.CALLBACK_URL,
})
)
)
);
console.log(`Submitted ${results.length} prompts`);Listing Available Models
const client = new ConvoyClient({ apiKey: process.env.CONVOY_API_KEY! });
const models = await client.models();
for (const model of models) {
console.log(` ${model.id} — ${model.description || 'No description'}`);
}Troubleshooting
TypeError: fetch is not a function
- Ensure you’re using Node.js 18+ (which includes built-in
fetch) - For older versions, install
node-fetch:npm install node-fetch
ConvoyError: 401 Unauthorized
- Verify your API key starts with
convoy_sk_ - Check the key hasn’t been revoked in the dashboard
- Ensure the header is
X-API-Key(case-sensitive)
ConvoyError: 422 Unprocessable Entity
- Check the request body format —
messagesmust be an array of{ role, content } - Verify the model ID is valid (use
client.models()) - Ensure
max_tokensis a positive integer
ConvoyError: 429 Too Many Requests
- You’ve hit the rate limit (60/min or 500/hour)
- Implement retry with backoff (see above)
- Use
p-limitto control concurrency
Polling timeout
- Batch processing can take minutes to hours depending on queue volume
- Increase the
timeoutoption inwaitForResult() - For production, use callbacks instead of polling
AbortError: The operation was aborted
- The request exceeded the timeout (default 30s)
- Increase the
timeoutoption in the client constructor - Check network connectivity to the Convoy API