Back to Resources
Technology Reference
Advanced
55 min
Chris MaskChris Mask
May 25, 2025

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:

MVP & Launch

Building your minimum viable product and preparing for market launch.

Best For Role:

Developers

Technical implementation guides and code examples for developers.

Expected Impact:

Strategic

Medium-term initiatives that build competitive advantages.

Platform: Platform Agnostic
Reading Level: Advanced

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:

  1. Pluralized nouns (/listings not /listing)
  2. No verbs in URLs (POST /listings not /create-listing)
  3. Nested for relationships (/listings/:id/reviews)
  4. 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:

  1. REST architecture (not GraphQL initially)
  2. JWT authentication with refresh tokens
  3. Rate limiting from day one
  4. Standard response format (success/error/meta)
  5. Zod validation for all inputs
  6. Webhooks for real-time integrations
  7. 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

  1. API audit: Review your current endpoints against these patterns
  2. Authentication upgrade: Migrate to JWT with refresh tokens
  3. Rate limiting implementation: Add Redis-based rate limiting
  4. Webhook design: Plan webhook events for key platform actions
  5. Documentation: Create OpenAPI specification

How much should your build actually cost?

Get a personalized investment estimate based on your platform type, scope, and timeline.

Open the Investment Calculator
#api-design
#rest-api
#authentication
#webhooks
#rate-limiting
#api-architecture
Found this helpful? Share it
Share:

About the Author

Chris Mask

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.