API Design for Internal Business Systems¶
I've built internal APIs for invoice management, revenue recognition, billing operations, and customer data access. None of them were exposed to external developers. All of them were critical business infrastructure.
The biggest mistake I see with internal APIs: treating them as throwaway code because they're "just internal." Six months later, you're debugging a production issue at 2 AM trying to figure out why POST /process returns a 200 status code but silently fails to actually process anything.
Your future self is your most important API consumer. Design accordingly.
Why Internal APIs Matter¶
Internal APIs power business operations. A poorly designed API creates technical debt that compounds:
- Every integration takes longer — unclear contracts mean more trial and error
- Bugs are harder to diagnose — cryptic errors obscure root causes
- Changes break things unexpectedly — no versioning means every update is a breaking change
- New team members struggle — undocumented assumptions block productivity
I've seen internal APIs that were so poorly designed that teams built workarounds rather than using them. That's not just technical debt — it's organizational debt.
REST Conventions for Business Operations¶
REST isn't perfect, but it provides a consistent mental model that makes APIs predictable. Here's how I apply REST principles to business domains:
Resource Naming: Nouns, Not Verbs¶
Bad:
POST /processInvoice
GET /getCustomerData
POST /calculateRevenue
Good:
POST /invoices/{id}/process
GET /customers/{id}
POST /revenue-calculations
The pattern: URLs represent resources (nouns), HTTP methods represent actions (verbs).
HTTP Methods Match Business Operations¶
| Method | Purpose | Example | Idempotent? |
|---|---|---|---|
| GET | Retrieve data | GET /invoices/12345 |
Yes |
| POST | Create new resource | POST /invoices |
No |
| PUT | Replace entire resource | PUT /invoices/12345 |
Yes |
| PATCH | Update partial resource | PATCH /invoices/12345 |
No* |
| DELETE | Remove resource | DELETE /invoices/12345 |
Yes |
Idempotency matters. If I retry a failed PUT request, I get the same result. If I retry a failed POST request, I might create duplicate records.
Practical Example: Invoice API¶
from flask import Flask, request, jsonify
from datetime import datetime
app = Flask(__name__)
# GET: Retrieve invoice by ID
@app.route('/api/v1/invoices/<invoice_id>', methods=['GET'])
def get_invoice(invoice_id):
"""Retrieve invoice details by ID"""
invoice = db.invoices.find_one({'invoice_id': invoice_id})
if not invoice:
return jsonify({
'error': 'not_found',
'message': f'Invoice {invoice_id} not found'
}), 404
return jsonify({
'invoice_id': invoice['invoice_id'],
'customer_id': invoice['customer_id'],
'amount': invoice['amount'],
'status': invoice['status'],
'created_at': invoice['created_at'].isoformat(),
'due_date': invoice['due_date'].isoformat()
}), 200
# POST: Create new invoice
@app.route('/api/v1/invoices', methods=['POST'])
def create_invoice():
"""Create a new invoice"""
data = request.get_json()
# Validate required fields
required_fields = ['customer_id', 'amount', 'due_date']
missing = [f for f in required_fields if f not in data]
if missing:
return jsonify({
'error': 'validation_error',
'message': f'Missing required fields: {", ".join(missing)}'
}), 400
# Business logic: Create invoice
invoice = {
'invoice_id': generate_invoice_id(),
'customer_id': data['customer_id'],
'amount': data['amount'],
'due_date': data['due_date'],
'status': 'draft',
'created_at': datetime.utcnow()
}
db.invoices.insert_one(invoice)
return jsonify({
'invoice_id': invoice['invoice_id'],
'status': 'created',
'message': 'Invoice created successfully'
}), 201
# PATCH: Update invoice status
@app.route('/api/v1/invoices/<invoice_id>', methods=['PATCH'])
def update_invoice(invoice_id):
"""Update invoice fields (partial update)"""
data = request.get_json()
# Validate invoice exists
invoice = db.invoices.find_one({'invoice_id': invoice_id})
if not invoice:
return jsonify({
'error': 'not_found',
'message': f'Invoice {invoice_id} not found'
}), 404
# Allow only specific fields to be updated
allowed_updates = ['status', 'amount', 'due_date', 'notes']
updates = {k: v for k, v in data.items() if k in allowed_updates}
if not updates:
return jsonify({
'error': 'validation_error',
'message': 'No valid fields to update'
}), 400
# Business logic: Status transitions
if 'status' in updates:
valid_transitions = {
'draft': ['pending', 'cancelled'],
'pending': ['paid', 'cancelled'],
'paid': [], # Cannot change from paid
'cancelled': [] # Cannot change from cancelled
}
current_status = invoice['status']
new_status = updates['status']
if new_status not in valid_transitions.get(current_status, []):
return jsonify({
'error': 'invalid_transition',
'message': f'Cannot transition from {current_status} to {new_status}'
}), 400
# Apply updates
db.invoices.update_one(
{'invoice_id': invoice_id},
{'$set': updates}
)
return jsonify({
'invoice_id': invoice_id,
'status': 'updated',
'message': 'Invoice updated successfully'
}), 200
# POST: Process invoice (business operation)
@app.route('/api/v1/invoices/<invoice_id>/process', methods=['POST'])
def process_invoice(invoice_id):
"""Process invoice (send to customer, update AR)"""
invoice = db.invoices.find_one({'invoice_id': invoice_id})
if not invoice:
return jsonify({
'error': 'not_found',
'message': f'Invoice {invoice_id} not found'
}), 404
if invoice['status'] != 'draft':
return jsonify({
'error': 'invalid_state',
'message': f'Cannot process invoice in {invoice["status"]} state'
}), 400
# Business logic: Process invoice
try:
send_invoice_to_customer(invoice)
update_accounts_receivable(invoice)
db.invoices.update_one(
{'invoice_id': invoice_id},
{'$set': {
'status': 'pending',
'processed_at': datetime.utcnow()
}}
)
return jsonify({
'invoice_id': invoice_id,
'status': 'processed',
'message': 'Invoice processed successfully'
}), 200
except Exception as e:
# Log error and return failure response
logger.error(f"Failed to process invoice {invoice_id}: {e}")
return jsonify({
'error': 'processing_error',
'message': 'Failed to process invoice',
'details': str(e)
}), 500
What this demonstrates:
- Consistent URL patterns — /invoices/{id} for operations on specific invoices
- HTTP methods match intent — GET retrieves, POST creates, PATCH updates
- Clear error responses — structured error objects with type and message
- Business logic validation — status transitions enforce business rules
- Idempotent operations where possible — GET and PATCH can be safely retried
Versioning: Plan for Change¶
Internal APIs change. Systems evolve. New requirements emerge. Without versioning, every change risks breaking existing integrations.
URL Versioning (Recommended for Internal APIs)¶
GET /api/v1/invoices/12345
GET /api/v2/invoices/12345
Advantages: - Version is explicit in every request - Easy to route different versions to different handlers - Clear communication of breaking changes - Simple to deprecate old versions
Implementation:
from flask import Flask
app = Flask(__name__)
# Version 1: Return amount as float
@app.route('/api/v1/invoices/<invoice_id>')
def get_invoice_v1(invoice_id):
invoice = db.invoices.find_one({'invoice_id': invoice_id})
return jsonify({
'invoice_id': invoice['invoice_id'],
'amount': float(invoice['amount']) # Float (can lose precision)
})
# Version 2: Return amount as string (preserves precision)
@app.route('/api/v2/invoices/<invoice_id>')
def get_invoice_v2(invoice_id):
invoice = db.invoices.find_one({'invoice_id': invoice_id})
return jsonify({
'invoice_id': invoice['invoice_id'],
'amount': str(invoice['amount']), # String (preserves precision)
'currency': 'USD' # New field
})
When to Bump the Version¶
Bump the version (breaking change) when you: - Remove a field from the response - Change the data type of a field (float → string) - Change required parameters - Change the meaning of a field - Change URL structure
Don't bump the version (backward compatible) when you: - Add a new optional parameter - Add a new field to the response - Add a new endpoint - Fix a bug that brings behavior in line with documentation
Deprecation Timeline¶
When you release v2, don't immediately kill v1. Give teams time to migrate:
- Release v2 — announce new version, document migration path
- Deprecation notice (3-6 months) — v1 still works but logs warnings
- Sunset date — v1 returns 410 Gone with message directing to v2
from datetime import datetime
@app.route('/api/v1/invoices/<invoice_id>')
def get_invoice_v1(invoice_id):
# Check if version is deprecated
sunset_date = datetime(2026, 6, 1)
if datetime.now() > sunset_date:
return jsonify({
'error': 'version_deprecated',
'message': 'API v1 has been deprecated. Please migrate to /api/v2/',
'sunset_date': sunset_date.isoformat()
}), 410 # HTTP 410 Gone
# Normal response (with deprecation warning header)
response = jsonify(get_invoice_data(invoice_id))
response.headers['Warning'] = '299 - "API v1 is deprecated. Migrate to v2 by June 2026."'
return response
Error Handling That Actually Helps¶
The worst error response I've seen in production:
{
"error": "Error"
}
This tells me nothing. What went wrong? What should I do differently?
Structured Error Responses¶
Every error response should include: - Type — machine-readable error code - Message — human-readable description - Details — specific information about what failed - Request ID — for tracing in logs
Standard error format:
from flask import jsonify, request
import uuid
def error_response(error_type, message, status_code, details=None):
"""Generate standardized error response"""
error_obj = {
'error': error_type,
'message': message,
'request_id': request.headers.get('X-Request-ID', str(uuid.uuid4())),
'timestamp': datetime.utcnow().isoformat()
}
if details:
error_obj['details'] = details
return jsonify(error_obj), status_code
# Usage examples
# 400 Bad Request - validation error
@app.route('/api/v1/invoices', methods=['POST'])
def create_invoice():
data = request.get_json()
if 'amount' not in data:
return error_response(
error_type='validation_error',
message='Missing required field: amount',
status_code=400,
details={'field': 'amount', 'required': True}
)
if data['amount'] <= 0:
return error_response(
error_type='validation_error',
message='Amount must be greater than zero',
status_code=400,
details={'field': 'amount', 'value': data['amount'], 'constraint': 'greater_than_zero'}
)
# 404 Not Found - resource doesn't exist
@app.route('/api/v1/invoices/<invoice_id>')
def get_invoice(invoice_id):
invoice = db.invoices.find_one({'invoice_id': invoice_id})
if not invoice:
return error_response(
error_type='not_found',
message=f'Invoice {invoice_id} not found',
status_code=404,
details={'invoice_id': invoice_id}
)
# 409 Conflict - business rule violation
@app.route('/api/v1/invoices/<invoice_id>/cancel', methods=['POST'])
def cancel_invoice(invoice_id):
invoice = db.invoices.find_one({'invoice_id': invoice_id})
if invoice['status'] == 'paid':
return error_response(
error_type='invalid_state',
message='Cannot cancel paid invoice',
status_code=409,
details={
'invoice_id': invoice_id,
'current_status': 'paid',
'allowed_statuses': ['draft', 'pending']
}
)
# 500 Internal Server Error - unexpected failure
@app.route('/api/v1/invoices/<invoice_id>/process', methods=['POST'])
def process_invoice(invoice_id):
try:
# Business logic
result = process_invoice_workflow(invoice_id)
return jsonify(result), 200
except DatabaseConnectionError as e:
logger.error(f"Database error processing invoice {invoice_id}: {e}")
return error_response(
error_type='database_error',
message='Unable to process invoice due to database error',
status_code=500,
details={'invoice_id': invoice_id, 'retry': True}
)
except Exception as e:
logger.error(f"Unexpected error processing invoice {invoice_id}: {e}")
return error_response(
error_type='internal_error',
message='An unexpected error occurred',
status_code=500,
details={'invoice_id': invoice_id}
)
Result: When something fails, developers can immediately understand what went wrong and how to fix it.
Documentation That Stays Current¶
The second-worst thing after no documentation is outdated documentation. It's worse than useless — it actively misleads.
Option 1: OpenAPI/Swagger (Auto-Generated)¶
Use a library that generates documentation from your code:
from flask import Flask
from flask_restx import Api, Resource, fields
app = Flask(__name__)
api = Api(app, version='1.0', title='Invoice API',
description='Internal API for invoice management')
# Define data models for automatic documentation
invoice_model = api.model('Invoice', {
'invoice_id': fields.String(required=True, description='Unique invoice identifier'),
'customer_id': fields.String(required=True, description='Customer identifier'),
'amount': fields.String(required=True, description='Invoice amount (decimal as string)'),
'status': fields.String(required=True, description='Invoice status', enum=['draft', 'pending', 'paid', 'cancelled']),
'due_date': fields.Date(required=True, description='Payment due date')
})
@api.route('/api/v1/invoices/<string:invoice_id>')
class InvoiceResource(Resource):
@api.doc('get_invoice')
@api.marshal_with(invoice_model)
def get(self, invoice_id):
"""Retrieve invoice by ID"""
invoice = db.invoices.find_one({'invoice_id': invoice_id})
if not invoice:
api.abort(404, f"Invoice {invoice_id} not found")
return invoice
@api.doc('update_invoice')
@api.expect(invoice_model)
@api.marshal_with(invoice_model)
def patch(self, invoice_id):
"""Update invoice fields"""
# Implementation
pass
Benefit: Documentation updates automatically when code changes. Swagger UI provides interactive API explorer.
Option 2: Inline Documentation¶
For smaller APIs, clear docstrings and comments may be sufficient:
@app.route('/api/v1/invoices/<invoice_id>/process', methods=['POST'])
def process_invoice(invoice_id):
"""
Process invoice for payment
URL: POST /api/v1/invoices/{invoice_id}/process
Path Parameters:
invoice_id (str): Unique invoice identifier
Request Body: None
Response (200):
{
"invoice_id": "INV-12345",
"status": "processed",
"message": "Invoice processed successfully"
}
Response (400):
{
"error": "invalid_state",
"message": "Cannot process invoice in paid state"
}
Response (404):
{
"error": "not_found",
"message": "Invoice INV-12345 not found"
}
Business Logic:
- Validates invoice exists and is in 'draft' status
- Sends invoice to customer via email
- Updates accounts receivable ledger
- Transitions invoice to 'pending' status
- Records processed timestamp
"""
# Implementation
The Bottom Line¶
Internal APIs power your business operations. They deserve the same design rigor as customer-facing APIs.
Apply REST conventions for consistency. Version your APIs to allow safe evolution. Return structured errors that help debugging. Keep documentation in sync with code.
Your future self — debugging a production issue six months from now — will thank you for the clarity.