Navigation

Programming

REST API Design Best Practices Tutorial 2025 Node.js Express Python Flask Examples

Learn REST API design principles through real-world examples from building payment APIs at a Seattle fintech startup, covering authentication, error handling, and scaling.
Jul 06, 2025
14 min read

REST APIs: How I Learned to Stop Worrying and Love HTTP

My first API looked like a war zone. Endpoints like /getUserDataById?id=123&includeOrders=true&format=json, inconsistent error messages, and authentication that worked “sometimes.” Then I joined RainCity FinTech, where our APIs handle millions of payment transactions daily, and inconsistency isn’t just bad practice - it’s expensive.

Here’s everything I learned about building REST APIs that don’t make other developers want to throw their laptops out of their Capitol Hill apartment windows.

In our previous post, we talked about SQL queries. Now it’s time to rest — if we’ve learned everything about SQL.

The API That Made Me Cry

Before I learned REST principles, my API looked like this disaster:

// The horror show that was my first API
app.get('/getUserStuff', (req, res) => {
    const userId = req.query.userId || req.body.userId || req.params.userId;
    const format = req.query.format || 'json';
    
    if (userId) {
        const user = database.getUser(userId);
        if (user) {
            if (format === 'xml') {
                res.send(`<user><name>${user.name}</name></user>`);
            } else {
                res.json({user: user, success: true, message: "OK"});
            }
        } else {
            res.status(404).send("User not found!");
        }
    } else {
        res.status(400).send("Missing user ID parameter");
    }
});

app.post('/deleteUser', (req, res) => {  // POST for DELETE? What was I thinking?
    // ... more chaos
});

My tech lead looked at this and said, “Maya, we need to talk about REST.”

REST: The Coffee Shop Menu Analogy

REST is like a well-organized coffee shop menu. Everything has its place, follows patterns, and uses the same language:

  • Resources: Things you can order (coffee, pastries, merchandise)
  • HTTP Methods: What you want to do (GET=look, POST=order, PUT=change order, DELETE=cancel)
  • Status Codes: How it went (200=perfect, 404=we’re out, 500=machine broke)
  • Consistent URLs: Clear paths to what you want
// RESTful coffee shop API
GET    /api/v1/drinks           // List all drinks
GET    /api/v1/drinks/123       // Get specific drink
POST   /api/v1/drinks           // Add new drink to menu
PUT    /api/v1/drinks/123       // Update drink completely
PATCH  /api/v1/drinks/123       // Update drink partially
DELETE /api/v1/drinks/123       // Remove drink from menu

GET    /api/v1/orders           // List orders
POST   /api/v1/orders           // Create new order
GET    /api/v1/orders/456       // Get specific order
PUT    /api/v1/orders/456       // Update entire order
DELETE /api/v1/orders/456       // Cancel order

Building a Real API: Payment Processing System

Let me show you how I built our payment API at the fintech startup:

Project Structure

payment-api/
├── src/
│   ├── controllers/
│   │   ├── auth.js
│   │   ├── payments.js
│   │   └── users.js
│   ├── middleware/
│   │   ├── auth.js
│   │   ├── validation.js
│   │   └── rateLimit.js
│   ├── models/
│   │   ├── User.js
│   │   └── Payment.js
│   ├── routes/
│   │   ├── auth.js
│   │   ├── payments.js
│   │   └── users.js
│   └── utils/
│       ├── database.js
│       └── logger.js
├── tests/
└── package.json

Basic Express.js Setup

// app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');

const authRoutes = require('./routes/auth');
const paymentRoutes = require('./routes/payments');
const userRoutes = require('./routes/users');
const { errorHandler } = require('./middleware/errorHandler');
const { requestLogger } = require('./middleware/logger');

const app = express();

// Security middleware
app.use(helmet());
app.use(cors({
    origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
    credentials: true
}));

// Rate limiting
const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100, // limit each IP to 100 requests per windowMs
    message: 'Too many requests from this IP'
});
app.use(limiter);

// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// Logging
app.use(requestLogger);

// Routes
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/payments', paymentRoutes);
app.use('/api/v1/users', userRoutes);

// Health check
app.get('/health', (req, res) => {
    res.json({ 
        status: 'healthy', 
        timestamp: new Date().toISOString(),
        uptime: process.uptime()
    });
});

// 404 handler
app.use('*', (req, res) => {
    res.status(404).json({
        error: 'Resource not found',
        message: `Cannot ${req.method} ${req.originalUrl}`
    });
});

// Error handling
app.use(errorHandler);

module.exports = app;

Authentication Middleware

// middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');

const authenticateToken = async (req, res, next) => {
    try {
        const authHeader = req.headers['authorization'];
        const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
        
        if (!token) {
            return res.status(401).json({
                error: 'Authentication required',
                message: 'No token provided'
            });
        }
        
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        const user = await User.findById(decoded.userId);
        
        if (!user) {
            return res.status(401).json({
                error: 'Authentication failed',
                message: 'Invalid token'
            });
        }
        
        req.user = user;
        next();
    } catch (error) {
        if (error.name === 'TokenExpiredError') {
            return res.status(401).json({
                error: 'Authentication failed',
                message: 'Token expired'
            });
        }
        
        return res.status(401).json({
            error: 'Authentication failed',
            message: 'Invalid token'
        });
    }
};

const requireRole = (roles) => {
    return (req, res, next) => {
        if (!req.user) {
            return res.status(401).json({
                error: 'Authentication required'
            });
        }
        
        if (!roles.includes(req.user.role)) {
            return res.status(403).json({
                error: 'Insufficient permissions',
                message: `Requires one of: ${roles.join(', ')}`
            });
        }
        
        next();
    };
};

module.exports = { authenticateToken, requireRole };

Payment Routes (The Heart of Our API)

// routes/payments.js
const express = require('express');
const { body, param, query } = require('express-validator');
const { authenticateToken } = require('../middleware/auth');
const { validate } = require('../middleware/validation');
const PaymentController = require('../controllers/payments');

const router = express.Router();

// All payment routes require authentication
router.use(authenticateToken);

// GET /api/v1/payments - List payments with filtering
router.get('/', [
    query('page').optional().isInt({ min: 1 }),
    query('limit').optional().isInt({ min: 1, max: 100 }),
    query('status').optional().isIn(['pending', 'completed', 'failed', 'cancelled']),
    query('startDate').optional().isISO8601(),
    query('endDate').optional().isISO8601(),
    validate
], PaymentController.listPayments);

// GET /api/v1/payments/:id - Get specific payment
router.get('/:id', [
    param('id').isUUID(),
    validate
], PaymentController.getPayment);

// POST /api/v1/payments - Create new payment
router.post('/', [
    body('amount').isFloat({ min: 0.01 }).withMessage('Amount must be positive'),
    body('currency').isIn(['USD', 'EUR', 'GBP']).withMessage('Invalid currency'),
    body('description').isLength({ min: 1, max: 255 }),
    body('recipientId').isUUID(),
    body('paymentMethod').isIn(['credit_card', 'bank_transfer', 'paypal']),
    validate
], PaymentController.createPayment);

// PUT /api/v1/payments/:id - Update payment (admin only)
router.put('/:id', [
    param('id').isUUID(),
    body('status').optional().isIn(['pending', 'completed', 'failed', 'cancelled']),
    body('amount').optional().isFloat({ min: 0.01 }),
    validate
], PaymentController.updatePayment);

// DELETE /api/v1/payments/:id - Cancel payment
router.delete('/:id', [
    param('id').isUUID(),
    validate
], PaymentController.cancelPayment);

// POST /api/v1/payments/:id/retry - Retry failed payment
router.post('/:id/retry', [
    param('id').isUUID(),
    validate
], PaymentController.retryPayment);

module.exports = router;

Payment Controller (Business Logic)

// controllers/payments.js
const Payment = require('../models/Payment');
const User = require('../models/User');
const { processPaymentWithStripe } = require('../services/stripe');
const { sendPaymentNotification } = require('../services/email');

class PaymentController {
    static async listPayments(req, res, next) {
        try {
            const {
                page = 1,
                limit = 20,
                status,
                startDate,
                endDate
            } = req.query;
            
            const filters = { userId: req.user.id };
            
            if (status) filters.status = status;
            if (startDate || endDate) {
                filters.createdAt = {};
                if (startDate) filters.createdAt.$gte = new Date(startDate);
                if (endDate) filters.createdAt.$lte = new Date(endDate);
            }
            
            const offset = (page - 1) * limit;
            const payments = await Payment.findMany({
                filters,
                limit: parseInt(limit),
                offset,
                orderBy: 'createdAt DESC'
            });
            
            const total = await Payment.count(filters);
            
            res.json({
                payments,
                pagination: {
                    page: parseInt(page),
                    limit: parseInt(limit),
                    total,
                    totalPages: Math.ceil(total / limit)
                }
            });
        } catch (error) {
            next(error);
        }
    }
    
    static async getPayment(req, res, next) {
        try {
            const { id } = req.params;
            
            const payment = await Payment.findOne({
                id,
                userId: req.user.id  // Users can only see their own payments
            });
            
            if (!payment) {
                return res.status(404).json({
                    error: 'Payment not found',
                    message: `Payment with ID ${id} not found`
                });
            }
            
            res.json({ payment });
        } catch (error) {
            next(error);
        }
    }
    
    static async createPayment(req, res, next) {
        try {
            const {
                amount,
                currency,
                description,
                recipientId,
                paymentMethod
            } = req.body;
            
            // Verify recipient exists
            const recipient = await User.findById(recipientId);
            if (!recipient) {
                return res.status(400).json({
                    error: 'Invalid recipient',
                    message: 'Recipient not found'
                });
            }
            
            // Check user balance (if applicable)
            if (req.user.balance < amount) {
                return res.status(400).json({
                    error: 'Insufficient funds',
                    message: 'Your account balance is insufficient for this payment'
                });
            }
            
            // Create payment record
            const payment = await Payment.create({
                userId: req.user.id,
                recipientId,
                amount,
                currency,
                description,
                paymentMethod,
                status: 'pending'
            });
            
            // Process payment asynchronously
            processPaymentWithStripe(payment.id)
                .then(result => {
                    return Payment.update(payment.id, { 
                        status: result.success ? 'completed' : 'failed',
                        externalId: result.transactionId,
                        failureReason: result.error
                    });
                })
                .then(() => {
                    return sendPaymentNotification(payment.id);
                })
                .catch(error => {
                    console.error('Payment processing error:', error);
                });
            
            res.status(201).json({
                payment,
                message: 'Payment created successfully'
            });
        } catch (error) {
            next(error);
        }
    }
    
    static async cancelPayment(req, res, next) {
        try {
            const { id } = req.params;
            
            const payment = await Payment.findOne({
                id,
                userId: req.user.id
            });
            
            if (!payment) {
                return res.status(404).json({
                    error: 'Payment not found'
                });
            }
            
            if (payment.status !== 'pending') {
                return res.status(400).json({
                    error: 'Cannot cancel payment',
                    message: `Payment is already ${payment.status}`
                });
            }
            
            await Payment.update(id, { 
                status: 'cancelled',
                cancelledAt: new Date()
            });
            
            res.json({
                message: 'Payment cancelled successfully'
            });
        } catch (error) {
            next(error);
        }
    }
}

module.exports = PaymentController;

One of the most important aspects of API design is using Git. Without version control, there’s no proper API design.

Consistent Error Handling

// middleware/errorHandler.js
const logger = require('../utils/logger');

class AppError extends Error {
    constructor(message, statusCode) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = true;
        
        Error.captureStackTrace(this, this.constructor);
    }
}

const errorHandler = (error, req, res, next) => {
    let err = { ...error };
    err.message = error.message;
    
    // Log error
    logger.error(error.stack);
    
    // Mongoose bad ObjectId
    if (error.name === 'CastError') {
        const message = 'Resource not found';
        err = new AppError(message, 404);
    }
    
    // Mongoose duplicate key
    if (error.code === 11000) {
        const message = 'Duplicate field value entered';
        err = new AppError(message, 400);
    }
    
    // Mongoose validation error
    if (error.name === 'ValidationError') {
        const message = Object.values(error.errors).map(val => val.message);
        err = new AppError(message, 400);
    }
    
    // JWT errors
    if (error.name === 'JsonWebTokenError') {
        const message = 'Invalid token';
        err = new AppError(message, 401);
    }
    
    if (error.name === 'TokenExpiredError') {
        const message = 'Token expired';
        err = new AppError(message, 401);
    }
    
    res.status(err.statusCode || 500).json({
        error: err.message || 'Internal server error',
        ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
    });
};

module.exports = { AppError, errorHandler };

API Documentation with OpenAPI

# swagger.yaml
openapi: 3.0.0
info:
  title: RainCity FinTech Payment API
  description: RESTful API for payment processing
  version: 1.0.0
  contact:
    name: Maya Chen
    email: [email protected]

servers:
  - url: https://api.raincityfintech.com/v1
    description: Production server
  - url: https://staging-api.raincityfintech.com/v1
    description: Staging server

paths:
  /payments:
    get:
      summary: List payments
      tags: [Payments]
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, completed, failed, cancelled]
      responses:
        '200':
          description: List of payments
          content:
            application/json:
              schema:
                type: object
                properties:
                  payments:
                    type: array
                    items:
                      $ref: '#/components/schemas/Payment'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
    
    post:
      summary: Create a new payment
      tags: [Payments]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency, description, recipientId, paymentMethod]
              properties:
                amount:
                  type: number
                  format: float
                  minimum: 0.01
                currency:
                  type: string
                  enum: [USD, EUR, GBP]
                description:
                  type: string
                  maxLength: 255
                recipientId:
                  type: string
                  format: uuid
                paymentMethod:
                  type: string
                  enum: [credit_card, bank_transfer, paypal]
      responses:
        '201':
          description: Payment created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  payment:
                    $ref: '#/components/schemas/Payment'
                  message:
                    type: string

components:
  schemas:
    Payment:
      type: object
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        recipientId:
          type: string
          format: uuid
        amount:
          type: number
          format: float
        currency:
          type: string
        description:
          type: string
        status:
          type: string
          enum: [pending, completed, failed, cancelled]
        paymentMethod:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
  
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

Testing Your API

// tests/payments.test.js
const request = require('supertest');
const app = require('../src/app');
const { generateToken } = require('../src/utils/auth');

describe('Payment API', () => {
    let authToken;
    let testUser;
    
    beforeEach(async () => {
        testUser = await createTestUser();
        authToken = generateToken(testUser.id);
    });
    
    describe('POST /api/v1/payments', () => {
        it('should create a payment successfully', async () => {
            const paymentData = {
                amount: 100.50,
                currency: 'USD',
                description: 'Test payment',
                recipientId: testUser.id,
                paymentMethod: 'credit_card'
            };
            
            const response = await request(app)
                .post('/api/v1/payments')
                .set('Authorization', `Bearer ${authToken}`)
                .send(paymentData)
                .expect(201);
            
            expect(response.body.payment).toBeDefined();
            expect(response.body.payment.amount).toBe(100.50);
            expect(response.body.payment.status).toBe('pending');
        });
        
        it('should return 400 for invalid amount', async () => {
            const paymentData = {
                amount: -10,  // Invalid negative amount
                currency: 'USD',
                description: 'Test payment',
                recipientId: testUser.id,
                paymentMethod: 'credit_card'
            };
            
            await request(app)
                .post('/api/v1/payments')
                .set('Authorization', `Bearer ${authToken}`)
                .send(paymentData)
                .expect(400);
        });
        
        it('should return 401 without authentication', async () => {
            const paymentData = {
                amount: 100.50,
                currency: 'USD',
                description: 'Test payment',
                recipientId: testUser.id,
                paymentMethod: 'credit_card'
            };
            
            await request(app)
                .post('/api/v1/payments')
                .send(paymentData)
                .expect(401);
        });
    });
});

API Versioning Strategy

// routes/index.js
const express = require('express');
const v1Routes = require('./v1');
const v2Routes = require('./v2');

const router = express.Router();

// Version 1 (current)
router.use('/v1', v1Routes);

// Version 2 (new features)
router.use('/v2', v2Routes);

// Default to latest version
router.use('/', v1Routes);

module.exports = router;

Rate Limiting and Security

// middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('../utils/redis');

// Different limits for different endpoints
const authLimiter = rateLimit({
    store: new RedisStore({
        client: redis
    }),
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // limit each IP to 5 auth requests per windowMs
    message: 'Too many authentication attempts, please try again later'
});

const paymentLimiter = rateLimit({
    store: new RedisStore({
        client: redis
    }),
    windowMs: 60 * 1000, // 1 minute
    max: 10, // limit each IP to 10 payment requests per minute
    message: 'Too many payment requests, please try again later'
});

module.exports = { authLimiter, paymentLimiter };

Monitoring and Logging

// middleware/monitoring.js
const prometheus = require('prom-client');

// Metrics
const httpDuration = new prometheus.Histogram({
    name: 'http_request_duration_seconds',
    help: 'Duration of HTTP requests in seconds',
    labelNames: ['method', 'route', 'status_code']
});

const httpRequests = new prometheus.Counter({
    name: 'http_requests_total',
    help: 'Total number of HTTP requests',
    labelNames: ['method', 'route', 'status_code']
});

const monitoringMiddleware = (req, res, next) => {
    const start = Date.now();
    
    res.on('finish', () => {
        const duration = (Date.now() - start) / 1000;
        const route = req.route ? req.route.path : req.path;
        
        httpDuration
            .labels(req.method, route, res.statusCode)
            .observe(duration);
            
        httpRequests
            .labels(req.method, route, res.statusCode)
            .inc();
    });
    
    next();
};

module.exports = { monitoringMiddleware };

Common REST API Mistakes (Learned the Hard Way)

Inconsistent Naming

// Bad: Inconsistent conventions
GET /api/getUsers          // verb in URL
GET /api/user-orders       // kebab-case
GET /api/userProfiles      // camelCase
DELETE /api/removeUser     // verb in URL

// Good: Consistent resource naming
GET /api/users             // plural nouns
GET /api/users/123/orders  // nested resources
GET /api/users/123/profile // singular profile
DELETE /api/users/123      // HTTP method shows intent

Poor Error Messages

// Bad: Unhelpful errors
res.status(400).send('Error');
res.status(500).json({error: 'Something went wrong'});

// Good: Descriptive errors
res.status(400).json({
    error: 'Validation failed',
    message: 'Amount must be a positive number',
    field: 'amount',
    code: 'INVALID_AMOUNT'
});

Ignoring HTTP Status Codes

// Bad: Always 200, even for errors
res.status(200).json({success: false, error: 'User not found'});

// Good: Proper status codes
res.status(404).json({error: 'User not found'});
res.status(201).json({user: newUser}); // Created
res.status(204).send(); // No content for successful delete

Final Thoughts: APIs as User Interfaces

That disaster of an API I showed you at the beginning? After learning REST principles, I refactored it into something I was proud of. Clean endpoints, predictable responses, comprehensive documentation. Other developers actually enjoyed using it.

Building good APIs isn’t just about following REST conventions - it’s about creating interfaces that other developers (including future you) can understand and use without wanting to scream. Whether you’re building a payment system that handles millions of transactions or a simple blog API, the principles remain the same.

Start with consistency, add proper error handling, document everything, and test thoroughly. Your fellow developers will thank you, and your 3 AM debugging sessions will become much less frequent.

Remember: a good API is like a good coffee shop - consistent, predictable, and you know exactly what to expect every time you walk in.


Currently testing our payment API endpoints from Lighthouse Roasters in Fremont, where the API documentation is as smooth as their cortado. Building your first API? Share your challenges @maya_codes_pnw - we’ve all been there! 🚀☕

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Programming