Skip to content

Creating API Endpoints - ThriveSend B2B2G

Last Updated: October 25, 2025


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.


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/analytics

Filename Convention: route.ts for API endpoints


src/app/api/example/route.ts
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 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:

../../../assets/images/screenshots/dev-api-get-clients.png
*Browser DevTools showing successful GET /api/clients request with response data*

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 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:

../../../assets/images/screenshots/dev-api-prisma-query.png
*VS Code showing Prisma query with IntelliSense autocomplete*

When creating a new API endpoint, ensure you include:

const { userId, orgId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!orgId) {
return NextResponse.json({ error: 'Organization required' }, { status: 400 });
}
// For POST/PUT requests
const body = await request.json();
// Validate required fields
if (!body.name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
// Validate data types
if (typeof body.name !== 'string') {
return NextResponse.json({ error: 'Name must be a string' }, { status: 400 });
}
// Log all personal data operations
console.log(
`[AUDIT] ${action} - User: ${userId}, Org: ${orgId}, ` +
`Resource: ${resourceId}, Time: ${new Date().toISOString()}`
);
try {
// API logic
} catch (error) {
console.error('[API] Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
// Success
return NextResponse.json({
success: true,
data: result,
meta: { /* optional metadata */ }
});
// Error
return NextResponse.json({
error: 'Error message',
code: 'ERROR_CODE'
}, { status: 400 });

src/app/api/resource/route.ts
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
}

src/app/api/clients/[clientId]/route.ts
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 });
}

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';
}

export async function POST(request: NextRequest) {
// Parse JSON body
const body = await request.json();
// Destructure expected fields
const { name, email, sector } = body;
}
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;
}

const client = await prisma.client.create({
data: {
name: 'New Client',
sector: 'BUSINESS',
organizationId: organization.id,
}
});
const clients = await prisma.client.findMany({
where: {
organizationId: organization.id,
sector: 'GOVERNMENT',
},
include: {
campaigns: true,
},
orderBy: {
createdAt: 'desc',
}
});
const client = await prisma.client.findUnique({
where: { id: clientId },
include: {
organization: true,
campaigns: {
where: { status: 'ACTIVE' }
}
}
});
const client = await prisma.client.update({
where: { id: clientId },
data: {
name: 'Updated Name',
updatedAt: new Date(),
}
});
const client = await prisma.client.delete({
where: { id: clientId }
});

Use appropriate HTTP status codes:

CodeUsageExample
200SuccessGET request successful
201CreatedPOST created new resource
204No ContentDELETE successful
400Bad RequestInvalid input data
401UnauthorizedMissing or invalid auth
403ForbiddenInsufficient permissions
404Not FoundResource doesn’t exist
500Server ErrorUnexpected error

// Log all data operations
console.log([
`[AUDIT]`,
`Action: ${action}`,
`User: ${userId}`,
`Org: ${orgId}`,
`Resource: ${resourceType}:${resourceId}`,
`Time: ${new Date().toISOString()}`,
`IP: ${request.headers.get('x-forwarded-for') || 'unknown'}`
].join(' | '));
// Never log sensitive data
console.log(`[AUDIT] User logged in: ${userId}`); // ✅ Good
console.log(`[AUDIT] Password: ${password}`); // ❌ Bad - Never log passwords
console.log(`[AUDIT] Email: ${email}`); // ❌ Bad - PII should not be in logs

Terminal window
# GET request
curl http://localhost:3000/api/clients \
-H "Authorization: Bearer YOUR_TOKEN"
# POST request
curl -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:

../../../assets/images/screenshots/dev-api-curl-test.png
*Terminal showing successful cURL request to /api/clients*

Screenshot Placeholder:

../../../assets/images/screenshots/dev-api-thunder-client.png
*Thunder Client extension in VS Code testing POST /api/clients*

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),
}
});
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