API Design Best Practices for Marketplaces
Learn REST API design patterns for marketplaces. Includes authentication, rate limiting, pagination, webhooks, and complete implementation examples.
Who Is This For?
This guide is specifically designed for:
Startup Stage:
Building your minimum viable product and preparing for market launch.
Best For Role:
Technical implementation guides and code examples for developers.
Expected Impact:
Medium-term initiatives that build competitive advantages.
What You'll Learn
- Design RESTful endpoint structures following marketplace patterns
- Implement JWT authentication with refresh token rotation
- Build Redis-based rate limiting for API protection
- Create pagination systems (offset and cursor-based)
- Design webhook systems with signature verification
- Apply API versioning strategies
- Implement comprehensive error handling and validation
Prerequisites
- •Understanding of RESTful API principles
- •Experience with HTTP methods and status codes
- •Knowledge of JWT authentication
- •Familiarity with API security concepts
API design decisions made during MVP development determine your platform's ability to support mobile apps, third-party integrations, and future scaling. This guide provides REST API patterns optimized for two-sided marketplace platforms.
REST vs GraphQL: Decision Framework
Use REST When:
- •Building MVP (faster implementation)
- •Simple data model (listings, users, transactions)
- •Team has strong REST experience
- •Need to be opinionated about data fetching
- •Caching is critical for performance
Use GraphQL When:
- •Complex data relationships requiring flexible queries
- •Building multiple clients (web, iOS, Android) with different data needs
- •Team has GraphQL expertise
- •Want clients to control data shape and reduce over-fetching
- •Already past MVP stage
Recommendation: Start with REST for 95% of marketplaces. Add GraphQL later if client needs demand it.
REST API Architecture
URL Structure Patterns
Design resource-based URLs following consistent patterns:
# Listings
GET /api/listings # List all active listings
GET /api/listings/:id # Get listing details
POST /api/listings # Create listing (sellers only)
PATCH /api/listings/:id # Update listing (seller/admin)
DELETE /api/listings/:id # Delete listing (seller/admin)
# Listing relationships
GET /api/listings/:id/reviews # Get listing reviews
POST /api/listings/:id/favorite # Favorite a listing
DELETE /api/listings/:id/favorite # Unfavorite
# Users
GET /api/users/me # Current user profile
PATCH /api/users/me # Update profile
GET /api/users/:id # Public user profile
GET /api/users/:id/listings # User's listings
GET /api/users/:id/reviews # User's reviews
# Transactions
GET /api/transactions # User's transactions
GET /api/transactions/:id # Transaction details
POST /api/transactions # Create transaction (checkout)
# Messages
GET /api/conversations # User's conversations
GET /api/conversations/:id # Conversation thread
POST /api/conversations/:id/messages # Send message
# Search
GET /api/search?q=desk&category=furniture&min_price=100
# Admin
GET /api/admin/users # List users (admin only)
PATCH /api/admin/users/:id/ban # Ban user (admin only)
GET /api/admin/stats # Platform stats
Key conventions:
- •Pluralized nouns (
/listingsnot/listing) - •No verbs in URLs (
POST /listingsnot/create-listing) - •Nested for relationships (
/listings/:id/reviews) - •Query params for filtering (
?status=active&category=furniture)
Request/Response Format
Implement a standard response wrapper for consistency:
// lib/api/response.ts
export type ApiResponse<T> = {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: any;
};
meta?: {
page?: number;
perPage?: number;
total?: number;
hasMore?: boolean;
};
};
export function successResponse<T>(data: T, meta?: any): ApiResponse<T> {
return {
success: true,
data,
meta,
};
}
export function errorResponse(
code: string,
message: string,
details?: any,
): ApiResponse<never> {
return {
success: false,
error: { code, message, details },
};
}
Example endpoint implementation:
// app/api/listings/route.ts
import { NextRequest } from "next/server";
import { successResponse, errorResponse } from "@/lib/api/response";
import { db } from "@/lib/db";
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") || "1");
const perPage = parseInt(searchParams.get("per_page") || "50");
const category = searchParams.get("category");
const status = searchParams.get("status") || "active";
const where: any = { status };
if (category) {
where.categoryId = category;
}
const [listings, total] = await Promise.all([
db.listing.findMany({
where,
include: {
seller: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
sellerRating: true,
},
},
category: true,
},
skip: (page - 1) * perPage,
take: perPage,
orderBy: { createdAt: "desc" },
}),
db.listing.count({ where }),
]);
return Response.json(
successResponse(listings, {
page,
perPage,
total,
totalPages: Math.ceil(total / perPage),
hasMore: page * perPage < total,
}),
);
} catch (error) {
console.error("Failed to fetch listings:", error);
return Response.json(
errorResponse("FETCH_FAILED", "Failed to fetch listings"),
{ status: 500 },
);
}
}
Authentication & Authorization
JWT with Refresh Tokens
Implement short-lived access tokens with long-lived refresh tokens:
// lib/auth/jwt.ts
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function signToken(payload: {
userId: string;
email: string;
role: string;
}) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m") // Short-lived access token
.sign(secret);
}
export async function signRefreshToken(userId: string) {
return await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d") // Long-lived refresh token
.sign(secret);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return payload as { userId: string; email: string; role: string };
} catch (error) {
return null;
}
}
Authentication Middleware
Create reusable middleware for protected routes:
// lib/api/auth.ts
import { NextRequest } from "next/server";
import { verifyToken } from "@/lib/auth/jwt";
export async function requireAuth(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return Response.json(
{
success: false,
error: { code: "UNAUTHORIZED", message: "Missing token" },
},
{ status: 401 },
);
}
const token = authHeader.substring(7);
const payload = await verifyToken(token);
if (!payload) {
return Response.json(
{
success: false,
error: { code: "INVALID_TOKEN", message: "Invalid or expired token" },
},
{ status: 401 },
);
}
return payload;
}
export async function requireSeller(request: NextRequest) {
const user = await requireAuth(request);
if (user instanceof Response) return user; // Error response
if (user.role !== "seller" && user.role !== "admin") {
return Response.json(
{
success: false,
error: { code: "FORBIDDEN", message: "Seller access required" },
},
{ status: 403 },
);
}
return user;
}
Usage in protected endpoints:
// app/api/listings/route.ts
export async function POST(request: NextRequest) {
// Require seller role
const user = await requireSeller(request);
if (user instanceof Response) return user;
const body = await request.json();
// Create listing for authenticated seller
const listing = await db.listing.create({
data: {
...body,
sellerId: user.userId,
status: "draft",
},
});
return Response.json(successResponse(listing), { status: 201 });
}
Rate Limiting
Redis-Based Rate Limiting
Implement sliding window rate limiting using Redis:
// lib/api/rate-limit.ts
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
export async function rateLimit({
identifier, // IP or user ID
limit = 100,
window = 60, // seconds
}: {
identifier: string;
limit?: number;
window?: number;
}): Promise<{
success: boolean;
limit: number;
remaining: number;
reset: number;
}> {
const key = `rate-limit:${identifier}`;
const now = Date.now();
const windowStart = now - window * 1000;
// Remove old entries
await redis.zremrangebyscore(key, 0, windowStart);
// Count requests in current window
const count = await redis.zcard(key);
if (count >= limit) {
// Get oldest entry to calculate reset time
const oldest = await redis.zrange(key, 0, 0, "WITHSCORES");
const reset = oldest[1]
? parseInt(oldest[1]) + window * 1000
: now + window * 1000;
return {
success: false,
limit,
remaining: 0,
reset: Math.ceil(reset / 1000),
};
}
// Add current request
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, window);
return {
success: true,
limit,
remaining: limit - count - 1,
reset: Math.ceil((now + window * 1000) / 1000),
};
}
// Middleware
export async function withRateLimit(
request: NextRequest,
handler: (request: NextRequest) => Promise<Response>,
) {
const identifier = request.headers.get("x-forwarded-for") || "unknown";
const result = await rateLimit({ identifier, limit: 100, window: 60 });
if (!result.success) {
return Response.json(
{
success: false,
error: {
code: "RATE_LIMIT_EXCEEDED",
message: "Too many requests",
},
},
{
status: 429,
headers: {
"X-RateLimit-Limit": result.limit.toString(),
"X-RateLimit-Remaining": result.remaining.toString(),
"X-RateLimit-Reset": result.reset.toString(),
},
},
);
}
const response = await handler(request);
// Add rate limit headers
response.headers.set("X-RateLimit-Limit", result.limit.toString());
response.headers.set("X-RateLimit-Remaining", result.remaining.toString());
response.headers.set("X-RateLimit-Reset", result.reset.toString());
return response;
}
Usage:
// app/api/search/route.ts
export async function GET(request: NextRequest) {
return withRateLimit(request, async (req) => {
// Your search logic
const results = await searchListings(...)
return Response.json(successResponse(results))
})
}
Pagination Patterns
Offset-Based Pagination (Simple)
Best for admin panels and traditional page navigation:
GET /api/listings?page=2&per_page=50
// Response
{
"success": true,
"data": [...],
"meta": {
"page": 2,
"perPage": 50,
"total": 1250,
"totalPages": 25,
"hasMore": true
}
}
Cursor-Based Pagination (Efficient)
Best for large datasets and infinite scroll:
// app/api/listings/route.ts
export async function GET(request: NextRequest) {
const cursor = request.nextUrl.searchParams.get('cursor')
const limit = 50
const listings = await db.listing.findMany({
take: limit + 1, // Fetch one extra to check for more
...(cursor && {
cursor: { id: cursor },
skip: 1, // Skip the cursor
}),
orderBy: { createdAt: 'desc' },
})
const hasMore = listings.length > limit
const data = hasMore ? listings.slice(0, -1) : listings
const nextCursor = hasMore ? data[data.length - 1].id : null
return Response.json(
successResponse(data, {
nextCursor,
hasMore,
})
)
}
// Usage
GET /api/listings?cursor=clxyz123
Recommendation: Use offset for admin panels, cursor for infinite scroll.
Error Handling
Standard Error Codes
Define consistent error codes across your API:
// lib/api/errors.ts
export const API_ERRORS = {
// Client errors (4xx)
BAD_REQUEST: { code: "BAD_REQUEST", status: 400 },
UNAUTHORIZED: { code: "UNAUTHORIZED", status: 401 },
FORBIDDEN: { code: "FORBIDDEN", status: 403 },
NOT_FOUND: { code: "NOT_FOUND", status: 404 },
VALIDATION_ERROR: { code: "VALIDATION_ERROR", status: 422 },
RATE_LIMIT_EXCEEDED: { code: "RATE_LIMIT_EXCEEDED", status: 429 },
// Server errors (5xx)
INTERNAL_ERROR: { code: "INTERNAL_ERROR", status: 500 },
DATABASE_ERROR: { code: "DATABASE_ERROR", status: 500 },
EXTERNAL_SERVICE_ERROR: { code: "EXTERNAL_SERVICE_ERROR", status: 502 },
};
export class ApiError extends Error {
constructor(
public code: string,
public message: string,
public status: number,
public details?: any,
) {
super(message);
}
}
// Global error handler
export function handleApiError(error: unknown) {
if (error instanceof ApiError) {
return Response.json(
errorResponse(error.code, error.message, error.details),
{ status: error.status },
);
}
// Unknown error
console.error("Unhandled error:", error);
return Response.json(
errorResponse("INTERNAL_ERROR", "An unexpected error occurred"),
{ status: 500 },
);
}
Validation with Zod
Use Zod for runtime type validation:
// app/api/listings/route.ts
import { z } from "zod";
const createListingSchema = z.object({
title: z.string().min(10).max(100),
description: z.string().min(50).max(5000),
categoryId: z.string().uuid(),
priceCents: z.number().int().positive().optional(),
pricingType: z.enum(["fixed", "negotiable", "contact"]).default("fixed"),
images: z.array(z.string().url()).min(1).max(10),
attributes: z.record(z.any()).optional(),
});
export async function POST(request: NextRequest) {
const user = await requireSeller(request);
if (user instanceof Response) return user;
try {
const body = await request.json();
// Validate
const validated = createListingSchema.parse(body);
// Create listing
const listing = await db.listing.create({
data: {
...validated,
sellerId: user.userId,
status: "draft",
},
});
return Response.json(successResponse(listing), { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
errorResponse("VALIDATION_ERROR", "Invalid request data", error.errors),
{ status: 422 },
);
}
return handleApiError(error);
}
}
Webhooks: Real-Time Updates
Webhook System Implementation
// lib/webhooks/sender.ts
import { db } from "@/lib/db";
import crypto from "crypto";
export async function sendWebhook({
url,
event,
data,
secret,
}: {
url: string;
event: string;
data: any;
secret: string;
}) {
const payload = JSON.stringify({
event,
data,
timestamp: Date.now(),
});
// Generate signature
const signature = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-Event": event,
},
body: payload,
signal: AbortSignal.timeout(5000), // 5s timeout
});
if (!response.ok) {
throw new Error(`Webhook failed: ${response.status}`);
}
return { success: true };
} catch (error) {
console.error("Webhook delivery failed:", error);
return { success: false, error };
}
}
// Retry logic with exponential backoff
export async function sendWebhookWithRetry(params: any, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const result = await sendWebhook(params);
if (result.success) {
return result;
}
// Exponential backoff: 2^attempt seconds
if (attempt < maxRetries - 1) {
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000),
);
}
}
return { success: false, error: "Max retries exceeded" };
}
Event Types
Define standard webhook events:
// Listing events
listing.created;
listing.updated;
listing.published;
listing.sold;
listing.deleted;
// Transaction events
transaction.created;
transaction.completed;
transaction.cancelled;
transaction.refunded;
// User events
user.created;
user.verified;
user.suspended;
// Message events
message.received;
conversation.created;
Client Verification
Implement signature verification on the receiving end:
// Client receives webhook
import crypto from "crypto";
export function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
}
// In webhook handler
export async function POST(request: Request) {
const signature = request.headers.get("x-webhook-signature");
const body = await request.text();
if (!verifyWebhookSignature(body, signature!, process.env.WEBHOOK_SECRET!)) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
const { event, data } = JSON.parse(body);
// Process event
switch (event) {
case "listing.created":
await handleNewListing(data);
break;
// ...
}
return Response.json({ received: true });
}
API Versioning
URL Versioning (Simple)
/api/v1/listings
/api/v2/listings
Header Versioning (Flexible)
// Client sends
headers: {
'Accept': 'application/vnd.marketplace.v2+json'
}
// Server detects
export function getApiVersion(request: NextRequest): number {
const accept = request.headers.get('accept')
const match = accept?.match(/v(\d+)/)
return match ? parseInt(match[1]) : 1
}
export async function GET(request: NextRequest) {
const version = getApiVersion(request)
if (version === 2) {
// New behavior
return Response.json(...)
}
// Legacy behavior
return Response.json(...)
}
Recommendation: Start without versioning. Add URL versioning when breaking changes are needed.
API Documentation
OpenAPI (Swagger) Specification
// lib/api/openapi.ts
export const openApiSpec = {
openapi: "3.0.0",
info: {
title: "Marketplace API",
version: "1.0.0",
description: "API for marketplace platform",
},
servers: [
{
url: "https://api.yourmarketplace.com",
description: "Production",
},
{
url: "http://localhost:3000/api",
description: "Development",
},
],
paths: {
"/listings": {
get: {
summary: "List all active listings",
tags: ["Listings"],
parameters: [
{
name: "page",
in: "query",
schema: { type: "integer", default: 1 },
},
{
name: "per_page",
in: "query",
schema: { type: "integer", default: 50 },
},
{
name: "category",
in: "query",
schema: { type: "string" },
},
],
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean" },
data: {
type: "array",
items: { $ref: "#/components/schemas/Listing" },
},
meta: { $ref: "#/components/schemas/PaginationMeta" },
},
},
},
},
},
},
},
},
},
components: {
schemas: {
Listing: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priceCents: { type: "integer" },
status: { type: "string", enum: ["draft", "active", "sold"] },
seller: { $ref: "#/components/schemas/User" },
},
},
},
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
};
// Serve at /api/docs
API Design Checklist
For 90% of marketplaces, implement:
- •REST architecture (not GraphQL initially)
- •JWT authentication with refresh tokens
- •Rate limiting from day one
- •Standard response format (success/error/meta)
- •Zod validation for all inputs
- •Webhooks for real-time integrations
- •OpenAPI documentation for external developers
Add later when needed:
- •GraphQL (if client needs demand it)
- •API versioning (only when breaking changes required)
- •Public API with OAuth (when partners request it)
Implementation Roadmap
Week 1: Core REST endpoints (listings, users, transactions) Week 2: Authentication and authorization middleware Week 3: Rate limiting and error handling Week 4: Pagination and filtering Week 5: Webhook system Week 6: API documentation (OpenAPI)
Next Steps
- •API audit: Review your current endpoints against these patterns
- •Authentication upgrade: Migrate to JWT with refresh tokens
- •Rate limiting implementation: Add Redis-based rate limiting
- •Webhook design: Plan webhook events for key platform actions
- •Documentation: Create OpenAPI specification
Related Resources
- •Database Architecture Patterns - Database schema design for marketplace backends
- •Scaling Marketplace Infrastructure - Performance optimization and scaling strategies
- •Authentication Security Guide - Advanced authentication and authorization patterns
How much should your build actually cost?
Get a personalized investment estimate based on your platform type, scope, and timeline.
Open the Investment CalculatorAbout the Author

Chris Mask
Founder & CEO
Serial entrepreneur, marketplace architect, and AI-assisted development pioneer with 7+ years building two-sided platforms. Founded Directorism after launching and exiting two successful marketplace businesses. Has personally architected and consulted on 200+ marketplace and directory projects. Recognized authority on cold-start problems, platform economics, marketplace SEO, and leveraging AI tools for rapid development. Early adopter of AI-powered coding workflows, integrating Claude, Cursor, and agentic development patterns into production systems.
Related Resources
Building Scalable Architecture: Technical Framework for Marketplaces
Learn how to architect marketplace platforms that scale to millions of users. Includes microservices patterns, database sharding, caching strategies, and deployment checklists.
Database Architecture Patterns for Marketplaces
Learn how to design scalable marketplace database schemas. Includes battle-tested patterns for users, listings, transactions, reviews, and messaging systems.
The Definitive Marketplace Tech Stack Guide for 2025
Choose the right tech stack for your marketplace. Learn proven architectures from Next.js to PostgreSQL, when to use each technology, and how to scale from MVP to 1M+ users with real cost projections.