- The API That Made Me Cry
- REST: The Coffee Shop Menu Analogy
- Building a Real API: Payment Processing System
- API Documentation with OpenAPI
- Testing Your API
- API Versioning Strategy
- Rate Limiting and Security
- Monitoring and Logging
- Common REST API Mistakes (Learned the Hard Way)
- Final Thoughts: APIs as User Interfaces
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! 🚀☕
Add Comment
No comments yet. Be the first to comment!