Creating API Endpoints - ThriveSend B2B2G
Creating API Endpoints - ThriveSend B2B2G
Section titled “Creating API Endpoints - ThriveSend B2B2G”Last Updated: October 25, 2025
Overview
Section titled “Overview”This guide shows you how to create new API endpoints following ThriveSend’s patterns and best practices. All examples use real code from the production codebase.
API Route Structure
Section titled “API Route Structure”ThriveSend uses Next.js 15 App Router with file-based routing:
src/app/api/├── clients/│ └── route.ts # GET /api/clients, POST /api/clients├── campaigns/│ ├── route.ts # GET /api/campaigns, POST /api/campaigns│ └── [campaignId]/│ └── route.ts # GET/PUT/DELETE /api/campaigns/[id]└── analytics/ └── route.ts # GET /api/analyticsFilename Convention: route.ts for API endpoints
Basic API Endpoint Pattern
Section titled “Basic API Endpoint Pattern”Minimal GET Endpoint
Section titled “Minimal GET Endpoint”import { auth } from '@clerk/nextjs/server';import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) { try { // 1. Authentication const { userId, orgId } = await auth();
if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
if (!orgId) { return NextResponse.json({ error: 'Organization required' }, { status: 400 }); }
// 2. Business logic const data = { message: 'Hello World', userId, orgId };
// 3. Return response return NextResponse.json({ success: true, data }); } catch (error) { console.error('[API] Error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); }}Real Example: Clients API
Section titled “Real Example: Clients API”GET /api/clients - List Clients
Section titled “GET /api/clients - List Clients”Real code from src/app/api/clients/route.ts:
import { auth } from '@clerk/nextjs/server';import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) { try { // Step 1: Clerk Authentication const { userId, orgId } = await auth();
if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
if (!orgId) { return NextResponse.json({ error: 'Organization required' }, { status: 400 }); }
// Step 2: Query Parameter Extraction const { searchParams } = new URL(request.url); const sector = searchParams.get('sector'); // ?sector=GOVERNMENT or BUSINESS
// Step 3: Data Filtering // In production, this would query Prisma: // const clients = await prisma.client.findMany({ where: { organizationId: orgId }});
const clients = [ { id: 'client-gov-1', name: 'City of Cape Town', type: 'LOCAL_MUNICIPALITY', sector: 'GOVERNMENT', securityClearance: 'BASIC', }, { id: 'client-biz-1', name: 'TechCorp Solutions', type: 'LARGE_CORPORATION', sector: 'BUSINESS', securityClearance: 'STANDARD', } ];
// Filter by sector if requested const filteredClients = sector ? clients.filter(client => client.sector === sector.toUpperCase()) : clients;
// Step 4: POPIA Audit Logging console.log( `[AUDIT] Clients data accessed - User: ${userId}, ` + `Org: ${orgId}, Sector: ${sector || 'ALL'}, ` + `Count: ${filteredClients.length}, Time: ${new Date().toISOString()}` );
// Step 5: Return Response with Metadata return NextResponse.json({ success: true, data: filteredClients, meta: { total: filteredClients.length, government: clients.filter(c => c.sector === 'GOVERNMENT').length, business: clients.filter(c => c.sector === 'BUSINESS').length } }); } catch (error) { console.error('[API] Clients error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); }}Screenshot Placeholder:
*Browser DevTools showing successful GET /api/clients request with response data*POST /api/clients - Create Client
Section titled “POST /api/clients - Create Client”Real code from src/app/api/clients/route.ts:
export async function POST(request: NextRequest) { try { // Step 1: Authentication const { userId, orgId } = await auth();
if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
if (!orgId) { return NextResponse.json({ error: 'Organization required' }, { status: 400 }); }
// Step 2: Parse Request Body const body = await request.json();
// Step 3: Validate Required Fields const requiredFields = ['name', 'type', 'industry', 'sector', 'contact']; for (const field of requiredFields) { if (!body[field]) { return NextResponse.json( { error: `${field} is required` }, { status: 400 } ); } }
// Step 4: Validate Sector if (!['GOVERNMENT', 'BUSINESS'].includes(body.sector)) { return NextResponse.json( { error: 'Sector must be GOVERNMENT or BUSINESS' }, { status: 400 } ); }
// Step 5: Create Client // In production with Prisma: // const client = await prisma.client.create({ data: { ...body, organizationId: orgId }});
const newClient = { id: `client-${Date.now()}`, ...body, status: 'PENDING', onboardedAt: new Date().toISOString(), serviceProviderId: orgId, createdBy: userId, compliance: { popiaCompliant: true, securityClearance: body.sector === 'GOVERNMENT' ? 'BASIC' : 'STANDARD', dataClassification: body.sector === 'GOVERNMENT' ? 'PUBLIC' : 'COMMERCIAL' } };
// Step 6: POPIA Audit Logging console.log( `[AUDIT] Client created - User: ${userId}, Org: ${orgId}, ` + `Client: ${newClient.id}, Name: ${newClient.name}, ` + `Sector: ${newClient.sector}, Time: ${new Date().toISOString()}` );
// Step 7: Return 201 Created return NextResponse.json( { success: true, data: newClient }, { status: 201 } ); } catch (error) { console.error('[API] Client creation error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); }}Real Example: Campaigns API with Prisma
Section titled “Real Example: Campaigns API with Prisma”GET /api/campaigns - List Campaigns
Section titled “GET /api/campaigns - List Campaigns”Real code from src/app/api/campaigns/route.ts (using Prisma):
import { auth } from '@clerk/nextjs/server';import { NextRequest, NextResponse } from 'next/server';import { prisma } from '@/lib/db';import { CampaignStatus } from '@prisma/client';
export async function GET(request: NextRequest) { try { // Step 1: Clerk Authentication const { userId, orgId } = await auth();
if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); }
if (!orgId) { return NextResponse.json({ error: 'Organization required' }, { status: 400 }); }
// Step 2: Get Organization from Database const organization = await prisma.organization.findUnique({ where: { clerkOrganizationId: orgId } });
if (!organization) { return NextResponse.json({ error: 'Organization not found' }, { status: 404 }); }
// Step 3: Extract Filter Parameters const { searchParams } = new URL(request.url); const clientId = searchParams.get('clientId'); const status = searchParams.get('status');
// Step 4: Build Prisma Where Clause const whereClause: any = { organizationId: organization.id, };
if (clientId && clientId !== 'ALL') { whereClause.clientId = clientId; }
if (status && status !== 'ALL') { whereClause.status = status as CampaignStatus; }
// Step 5: Query Campaigns with Relations const dbCampaigns = await prisma.campaign.findMany({ where: whereClause, include: { client: { select: { id: true, name: true, type: true, governmentLevel: true, }, }, _count: { select: { content: true, analytics: true, }, }, }, orderBy: { createdAt: 'desc' }, });
// Step 6: Transform Database Results to API Format const campaigns = dbCampaigns.map(campaign => { // Determine sector from client type let clientSector: 'GOVERNMENT' | 'BUSINESS' | null = null; if (campaign.client) { const governmentTypes = [ 'MUNICIPALITY', 'PROVINCIAL_GOVT', 'NATIONAL_GOVT', 'STATE_OWNED_ENTITY' ]; clientSector = governmentTypes.includes(campaign.client.type) ? 'GOVERNMENT' : 'BUSINESS'; }
return { id: campaign.id, name: campaign.name, clientId: campaign.clientId, clientName: campaign.client?.name || 'No Client', clientSector, type: campaign.type, status: campaign.status, contentCount: campaign._count.content, analyticsCount: campaign._count.analytics, createdAt: campaign.createdAt, }; });
// Step 7: Return Response return NextResponse.json({ success: true, data: campaigns, meta: { total: campaigns.length, } }); } catch (error) { console.error('[API] Campaigns error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); }}Screenshot Placeholder:
*VS Code showing Prisma query with IntelliSense autocomplete*API Endpoint Checklist
Section titled “API Endpoint Checklist”When creating a new API endpoint, ensure you include:
1. ✅ Authentication
Section titled “1. ✅ Authentication”const { userId, orgId } = await auth();
if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });}
if (!orgId) { return NextResponse.json({ error: 'Organization required' }, { status: 400 });}2. ✅ Input Validation
Section titled “2. ✅ Input Validation”// For POST/PUT requestsconst body = await request.json();
// Validate required fieldsif (!body.name) { return NextResponse.json({ error: 'Name is required' }, { status: 400 });}
// Validate data typesif (typeof body.name !== 'string') { return NextResponse.json({ error: 'Name must be a string' }, { status: 400 });}3. ✅ POPIA Audit Logging
Section titled “3. ✅ POPIA Audit Logging”// Log all personal data operationsconsole.log( `[AUDIT] ${action} - User: ${userId}, Org: ${orgId}, ` + `Resource: ${resourceId}, Time: ${new Date().toISOString()}`);4. ✅ Error Handling
Section titled “4. ✅ Error Handling”try { // API logic} catch (error) { console.error('[API] Error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });}5. ✅ Consistent Response Format
Section titled “5. ✅ Consistent Response Format”// Successreturn NextResponse.json({ success: true, data: result, meta: { /* optional metadata */ }});
// Errorreturn NextResponse.json({ error: 'Error message', code: 'ERROR_CODE'}, { status: 400 });HTTP Methods
Section titled “HTTP Methods”Supported Methods
Section titled “Supported Methods”export async function GET(request: NextRequest) { // List or retrieve resources}
export async function POST(request: NextRequest) { // Create new resource}
export async function PUT(request: NextRequest) { // Update existing resource (full update)}
export async function PATCH(request: NextRequest) { // Partial update of resource}
export async function DELETE(request: NextRequest) { // Delete resource}Dynamic Routes
Section titled “Dynamic Routes”Pattern: [id] Parameter
Section titled “Pattern: [id] Parameter”interface RouteParams { params: { clientId: string }}
export async function GET( request: NextRequest, { params }: RouteParams) { const { clientId } = params;
// Fetch specific client const client = await prisma.client.findUnique({ where: { id: clientId } });
if (!client) { return NextResponse.json({ error: 'Client not found' }, { status: 404 }); }
return NextResponse.json({ success: true, data: client });}Query Parameters
Section titled “Query Parameters”Extracting Query Params
Section titled “Extracting Query Params”export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url);
// Single parameters const sector = searchParams.get('sector'); // ?sector=GOVERNMENT const page = searchParams.get('page') || '1'; // ?page=2
// Multiple values const tags = searchParams.getAll('tags'); // ?tags=email&tags=sms
// Type conversion const limit = parseInt(searchParams.get('limit') || '10', 10);
// Boolean const active = searchParams.get('active') === 'true';}Request Body Parsing
Section titled “Request Body Parsing”JSON Body
Section titled “JSON Body”export async function POST(request: NextRequest) { // Parse JSON body const body = await request.json();
// Destructure expected fields const { name, email, sector } = body;}Form Data
Section titled “Form Data”export async function POST(request: NextRequest) { // Parse form data const formData = await request.formData();
const name = formData.get('name'); const file = formData.get('file') as File;}Database Operations with Prisma
Section titled “Database Operations with Prisma”Create
Section titled “Create”const client = await prisma.client.create({ data: { name: 'New Client', sector: 'BUSINESS', organizationId: organization.id, }});Read (Find Many)
Section titled “Read (Find Many)”const clients = await prisma.client.findMany({ where: { organizationId: organization.id, sector: 'GOVERNMENT', }, include: { campaigns: true, }, orderBy: { createdAt: 'desc', }});Read (Find Unique)
Section titled “Read (Find Unique)”const client = await prisma.client.findUnique({ where: { id: clientId }, include: { organization: true, campaigns: { where: { status: 'ACTIVE' } } }});Update
Section titled “Update”const client = await prisma.client.update({ where: { id: clientId }, data: { name: 'Updated Name', updatedAt: new Date(), }});Delete
Section titled “Delete”const client = await prisma.client.delete({ where: { id: clientId }});Response Status Codes
Section titled “Response Status Codes”Use appropriate HTTP status codes:
| Code | Usage | Example |
|---|---|---|
| 200 | Success | GET request successful |
| 201 | Created | POST created new resource |
| 204 | No Content | DELETE successful |
| 400 | Bad Request | Invalid input data |
| 401 | Unauthorized | Missing or invalid auth |
| 403 | Forbidden | Insufficient permissions |
| 404 | Not Found | Resource doesn’t exist |
| 500 | Server Error | Unexpected error |
POPIA Compliance
Section titled “POPIA Compliance”Audit Logging Pattern
Section titled “Audit Logging Pattern”// Log all data operationsconsole.log([ `[AUDIT]`, `Action: ${action}`, `User: ${userId}`, `Org: ${orgId}`, `Resource: ${resourceType}:${resourceId}`, `Time: ${new Date().toISOString()}`, `IP: ${request.headers.get('x-forwarded-for') || 'unknown'}`].join(' | '));Sensitive Data Handling
Section titled “Sensitive Data Handling”// Never log sensitive dataconsole.log(`[AUDIT] User logged in: ${userId}`); // ✅ Good
console.log(`[AUDIT] Password: ${password}`); // ❌ Bad - Never log passwordsconsole.log(`[AUDIT] Email: ${email}`); // ❌ Bad - PII should not be in logsTesting Your API
Section titled “Testing Your API”Using cURL
Section titled “Using cURL”# GET requestcurl http://localhost:3000/api/clients \ -H "Authorization: Bearer YOUR_TOKEN"
# POST requestcurl -X POST http://localhost:3000/api/clients \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Test Client", "sector": "BUSINESS", "type": "MEDIUM_BUSINESS", "industry": "Technology", "contact": { "email": "test@example.com" } }'Screenshot Placeholder:
*Terminal showing successful cURL request to /api/clients*Using Thunder Client (VS Code)
Section titled “Using Thunder Client (VS Code)”Screenshot Placeholder:
*Thunder Client extension in VS Code testing POST /api/clients*Common Patterns
Section titled “Common Patterns”Pagination
Section titled “Pagination”const page = parseInt(searchParams.get('page') || '1', 10);const limit = parseInt(searchParams.get('limit') || '10', 10);const skip = (page - 1) * limit;
const [items, total] = await Promise.all([ prisma.client.findMany({ where: whereClause, skip, take: limit, }), prisma.client.count({ where: whereClause })]);
return NextResponse.json({ success: true, data: items, meta: { total, page, limit, pages: Math.ceil(total / limit), }});Search/Filter
Section titled “Search/Filter”const search = searchParams.get('search');
const whereClause: any = { organizationId: organization.id,};
if (search) { whereClause.name = { contains: search, mode: 'insensitive', // Case-insensitive search };}Last Updated: October 25, 2025