APIs Explained: How Backend Services Communicate
By Oleksandr Andrushchenko — Published on — Modified on
APIs are the contracts that allow backend systems, frontend applications, mobile apps, third-party integrations, and internal services to communicate. A good API hides implementation details while exposing a stable way to request data, trigger actions, and exchange information between systems.
In production systems, APIs are not only endpoints and JSON responses. They define boundaries between services, control coupling, enforce security, shape performance, and determine how systems evolve without breaking clients.
Table of Contents
- What Is an API?
- Why APIs Exist
- How Backend Communication Works
- Common API Styles
- API Contract Design
- Production API Concerns
- Ready-to-Use Example
- Common Mistakes
- Production Checklist
- Conclusion
- More Articles to Read
What Is an API?
An Application Programming Interface is a defined way for one software system to interact with another. In backend systems, an API usually exposes operations over HTTP, messaging, RPC, or events.
The API is not the database, framework, or internal code structure. It is the external contract that other systems depend on.
| API Consumer | API Provider | Typical Purpose |
|---|---|---|
| Frontend app | Backend API | Read and modify user-facing data. |
| Mobile app | Backend API | Perform authenticated user operations. |
| Internal service | Another backend service | Coordinate business workflows. |
| Third-party system | Public API | Integrate external platforms. |
Why APIs Exist
APIs exist because systems need boundaries. Without a defined interface, every consumer depends directly on implementation details such as database tables, internal classes, storage layout, or deployment structure.
Key Point: a good API allows internal implementation to change while external behavior stays stable.
Service Boundaries
A backend service boundary separates ownership, data, deployment, and responsibility. The API is the controlled entry point across that boundary.
Frontend
-> User API
-> User database
Orders Service
-> Payments API
-> Payment provider
Admin Tool
-> Reporting API
-> Analytics database
Boundaries reduce accidental coupling. A client should not need to know how data is stored, which cache is used, or which internal services are called.
Contracts Between Systems
An API contract defines what requests are accepted, what responses are returned, which errors are possible, and what behavior clients can rely on.
| Contract Part | Example |
|---|---|
| Endpoint | GET /v1/orders/{order_id} |
| Request | Path parameters, query parameters, headers, body. |
| Response | Status code, JSON body, headers. |
| Error behavior | 404 when order does not exist. |
| Security | Authentication token and required permissions. |
How Backend Communication Works
Backend communication usually follows one of two models: synchronous communication, where the caller waits for a response, or asynchronous communication, where work is sent for later processing.
The choice affects latency, reliability, failure handling, and system coupling.
Request/Response Model
The request/response model is the most common API communication pattern. A client sends a request, the server processes it, and the client receives a response.
Client
-> HTTP request
-> API server
-> Validate input
-> Check permissions
-> Read or write data
-> HTTP response
This model is simple and direct. It works well for operations where the caller needs an immediate result.
Synchronous vs Asynchronous Communication
Synchronous APIs are easier to reason about but create tighter runtime coupling. If the provider is slow or unavailable, the caller is directly affected.
Asynchronous communication uses queues, events, or streams to decouple producers from consumers. It improves resilience but introduces eventual consistency.
| Pattern | Best For | Trade-Off |
|---|---|---|
| Synchronous API | Immediate reads, validation, user-facing actions. | Caller waits for response. |
| Asynchronous message | Email sending, file processing, notifications, imports. | Result may be delayed. |
| Event | System integration and decoupled workflows. | Harder tracing and consistency model. |
Rule of Thumb: use synchronous APIs when the caller needs an immediate answer. Use asynchronous communication when the work is slow, retryable, or not required before responding.
Common API Styles
Different API styles exist because systems have different communication needs. REST, GraphQL, gRPC, and webhooks solve different problems.
The best style depends on consumer type, latency requirements, payload shape, platform constraints, and operational maturity.
REST APIs
REST APIs model operations around resources and standard HTTP methods. They are widely used for web applications, mobile apps, public APIs, and internal services.
GET /v1/orders
GET /v1/orders/ord_123
POST /v1/orders
PATCH /v1/orders/ord_123
DELETE /v1/orders/ord_123
REST is a strong default when resources are clear and HTTP semantics fit the problem.
GraphQL APIs
GraphQL allows clients to request exactly the fields they need. It is useful when frontend clients need flexible data shapes from many related resources.
The trade-off is operational complexity. Query cost, authorization, caching, and observability require careful design.
gRPC APIs
gRPC uses strongly typed contracts and efficient binary communication. It is common for internal service-to-service communication where performance and strict schemas matter.
gRPC is less convenient for browser-first public APIs, but it can be excellent for internal backend systems.
Webhooks
Webhooks are reverse APIs. Instead of a client polling for changes, the provider sends an HTTP request to a client-controlled endpoint when an event happens.
Payment provider
-> POST /webhooks/payments
-> Application validates signature
-> Application stores event
-> Worker processes event
Production Note: webhook handlers should be idempotent because providers often retry delivery.
API Contract Design
API design is contract design. A good API contract is predictable, consistent, secure, and stable across versions.
Poor contract design creates long-term maintenance problems because clients build assumptions around every response field, error code, and behavior.
Resources, Actions, and Events
Resource-based APIs work well when the system exposes entities such as users, orders, invoices, payments, or projects. Action-based endpoints are useful when the operation does not fit clean CRUD behavior.
| API Shape | Example | Best Fit |
|---|---|---|
| Resource | GET /v1/orders/ord_123 |
Reading or modifying entities. |
| Action | POST /v1/orders/ord_123/cancel |
Business operation on a resource. |
| Event | order.created |
Asynchronous system notification. |
Status Codes and Errors
Status codes should communicate the category of result. Error bodies should provide enough detail for clients to recover or display a useful message.
| Status Code | Meaning | Example |
|---|---|---|
| 200 | Success | Resource returned. |
| 201 | Created | New order created. |
| 400 | Invalid request | Validation failed. |
| 401 | Not authenticated | Missing or invalid token. |
| 403 | Not authorized | Authenticated but not allowed. |
| 404 | Not found | Order does not exist. |
| 409 | Conflict | State does not allow operation. |
| 429 | Rate limited | Too many requests. |
| 500 | Server error | Unexpected failure. |
Backward Compatibility
APIs should evolve without breaking existing clients. Removing fields, changing data types, changing meanings, or tightening validation can break consumers.
Rule of Thumb: adding optional fields is usually safe. Removing or changing existing fields is usually breaking.
{
"error": {
"code": "order_not_found",
"message": "Order was not found.",
"request_id": "req_9d2a81"
}
}
Stable error codes are important. Clients should not need to parse human-readable messages to make decisions.
Production API Concerns
Production APIs need more than clean endpoint names. They need security, timeouts, retries, rate limits, observability, and failure behavior that clients can handle.
These concerns should be part of the API design from the beginning.
Authentication and Authorization
Authentication identifies who is calling. Authorization decides whether that caller is allowed to perform the requested operation.
Request
-> Verify token
-> Load identity
-> Check permission
-> Execute operation
-> Return response
Important: authentication alone is not enough. A valid user token does not automatically mean access to every resource.
Timeouts and Retries
Backend services should use explicit timeouts when calling other APIs. Without timeouts, one slow dependency can consume threads, connections, workers, or event-loop capacity.
Retries should be limited and should use backoff. Retrying every failed request immediately can amplify incidents.
| Failure | Retry? | Reason |
|---|---|---|
| Network timeout | Usually yes | May be temporary. |
| HTTP 500 | Sometimes | Depends on operation idempotency. |
| HTTP 400 | No | Request is invalid. |
| HTTP 401 | No | Authentication problem. |
| HTTP 429 | Yes, after delay | Respect rate limit response. |
Rate Limiting
Rate limiting protects APIs from abuse, accidental overload, noisy clients, and traffic spikes. It can be applied per user, token, IP address, organization, endpoint, or plan.
Rate limits should return clear responses and should not look like random server failures.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{
"error": {
"code": "rate_limit_exceeded",
"message": "Too many requests. Retry later."
}
}
Observability
APIs should be observable through logs, metrics, traces, and request identifiers. Without observability, production debugging becomes guesswork.
- Request ID for tracing one request across services.
- Latency metrics for user-facing performance.
- Error rate for service health.
- Status code distribution for client and server behavior.
- Dependency timing for database and downstream API calls.
Ready-to-Use Example
This example shows a small production-oriented Orders API contract, a FastAPI implementation, and a client script with timeouts and error handling.
The goal is not to build a full application. The goal is to show the shape of a practical API contract and the operational details that should exist from the beginning.
OpenAPI Example
The OpenAPI snippet defines one resource endpoint and one action endpoint. It includes status codes, stable error response shape, and bearer authentication.
openapi: 3.0.3
info:
title: Orders API
version: "1.0"
paths:
/v1/orders/{order_id}:
get:
summary: Get an order by ID
description: Returns one order visible to the authenticated caller.
security:
- bearerAuth: []
parameters:
- name: order_id
in: path
required: true
schema:
type: string
description: Stable public order identifier, not a database ID.
responses:
"200":
description: Order found.
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"401":
description: Missing or invalid authentication token.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"403":
description: Caller is authenticated but cannot access this order.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Order does not exist or is not visible to the caller.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/v1/orders/{order_id}/cancel:
post:
summary: Cancel an order
description: Performs a business action on an existing order.
security:
- bearerAuth: []
parameters:
- name: order_id
in: path
required: true
schema:
type: string
responses:
"200":
description: Order was canceled.
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"409":
description: Order cannot be canceled in its current state.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Order:
type: object
required:
- id
- status
- total_cents
properties:
id:
type: string
example: ord_123
status:
type: string
example: paid
total_cents:
type: integer
example: 1299
ErrorResponse:
type: object
required:
- error
properties:
error:
type: object
required:
- code
- message
- request_id
properties:
code:
type: string
example: order_not_found
message:
type: string
example: Order was not found.
request_id:
type: string
example: req_9d2a81
FastAPI Example
The FastAPI example shows stable error responses, request IDs, authorization checks, and clear status codes.
from fastapi import FastAPI, Header, HTTPException, Request
from pydantic import BaseModel
from uuid import uuid4
app = FastAPI(title="Orders API")
class OrderResponse(BaseModel):
id: str
status: str
total_cents: int
class ApiError(Exception):
def __init__(self, status_code: int, code: str, message: str):
self.status_code = status_code
self.code = code
self.message = message
ORDERS = {
"ord_123": {
"id": "ord_123",
"owner_id": "user_1",
"status": "paid",
"total_cents": 1299,
}
}
@app.middleware("http")
async def add_request_id(request: Request, call_next):
# Use caller-provided request ID when present, otherwise generate one.
# This makes logs easier to connect across API gateway, app, and clients.
request.state.request_id = request.headers.get("x-request-id", f"req_{uuid4().hex[:8]}")
response = await call_next(request)
response.headers["x-request-id"] = request.state.request_id
return response
@app.exception_handler(ApiError)
async def handle_api_error(request: Request, exc: ApiError):
# Keep a consistent error shape so clients can handle failures reliably.
return {
"status_code": exc.status_code,
"content": {
"error": {
"code": exc.code,
"message": exc.message,
"request_id": request.state.request_id,
}
},
}
def get_current_user_id(authorization: str | None) -> str:
# This is simplified for article clarity.
# Production code should verify JWT signature, issuer, audience, and expiration.
if not authorization or not authorization.startswith("Bearer "):
raise ApiError(401, "unauthenticated", "Authentication token is required.")
return "user_1"
def load_order_for_user(order_id: str, user_id: str) -> dict:
order = ORDERS.get(order_id)
# Avoid leaking whether another user's order exists.
# Returning 404 is often safer than returning 403 for invisible resources.
if not order or order["owner_id"] != user_id:
raise ApiError(404, "order_not_found", "Order was not found.")
return order
@app.get("/v1/orders/{order_id}", response_model=OrderResponse)
def get_order(order_id: str, authorization: str | None = Header(default=None)):
user_id = get_current_user_id(authorization)
order = load_order_for_user(order_id, user_id)
return OrderResponse(
id=order["id"],
status=order["status"],
total_cents=order["total_cents"],
)
@app.post("/v1/orders/{order_id}/cancel", response_model=OrderResponse)
def cancel_order(order_id: str, authorization: str | None = Header(default=None)):
user_id = get_current_user_id(authorization)
order = load_order_for_user(order_id, user_id)
# Business-state conflicts should use 409 instead of generic 400 or 500.
if order["status"] not in {"created", "paid"}:
raise ApiError(409, "order_not_cancelable", "Order cannot be canceled.")
order["status"] = "canceled"
return OrderResponse(
id=order["id"],
status=order["status"],
total_cents=order["total_cents"],
)
Production Note: real exception handlers should return proper framework response objects. The important design point is the stable error shape, not the specific framework syntax.
Client Script Example
API clients should set timeouts, send request IDs, and handle status codes intentionally.
import requests
from uuid import uuid4
API_BASE_URL = "https://api.example.com"
TOKEN = "replace-with-real-token"
request_id = f"req_{uuid4().hex[:8]}"
try:
response = requests.get(
f"{API_BASE_URL}/v1/orders/ord_123",
headers={
# Bearer token authenticates the caller.
"Authorization": f"Bearer {TOKEN}",
# Request ID allows support and logs to trace this call.
"X-Request-ID": request_id,
},
# Always set timeouts. Infinite waits can exhaust workers or threads.
timeout=3,
)
if response.status_code == 200:
order = response.json()
print(order)
elif response.status_code == 404:
print("Order does not exist or is not visible to this caller.")
elif response.status_code == 429:
print("Rate limited. Retry later using server guidance.")
elif response.status_code >= 500:
print("Server error. Retry only if the operation is safe to retry.")
else:
print(f"Unexpected API response: {response.status_code}", response.text)
except requests.Timeout:
# Timeout handling is required for production API clients.
print(f"Request timed out. request_id={request_id}")
except requests.RequestException as exc:
# Network errors should be handled separately from application errors.
print(f"Request failed. request_id={request_id}. error={exc}")
Common Mistakes
Most API mistakes come from treating endpoints as simple controller methods instead of long-term contracts between systems.
- Exposing database structure directly through API responses.
- Changing response fields without considering existing clients.
- Using generic errors that clients cannot handle programmatically.
- Returning 500 for validation or business-state problems.
- Skipping authorization checks after authentication succeeds.
- Calling slow downstream services without timeouts.
- Retrying non-idempotent operations blindly.
- Doing slow work synchronously when a queue would be safer.
- Ignoring rate limits until the API is overloaded.
- Missing request IDs, making production debugging difficult.
Production Checklist
A production API should be stable, observable, secure, and predictable under failure.
- Define API contracts explicitly with OpenAPI, Protobuf, or another schema format.
- Use stable resource identifiers instead of leaking internal database IDs when possible.
- Return consistent error responses with stable error codes.
- Separate authentication and authorization.
- Use timeouts for downstream calls.
- Retry only safe operations with backoff.
- Apply rate limits to protect the service.
- Use request IDs across logs, responses, and downstream calls.
- Monitor latency, error rate, traffic, and dependency failures.
- Keep APIs backward-compatible across releases.
- Move slow or retryable work to queues.
- Document status codes and error behavior.
Conclusion
APIs are the communication contracts between backend systems, clients, and integrations. They define how data is requested, how actions are triggered, how failures are reported, and how systems remain decoupled.
A production API is not only a URL and a JSON response. It is a long-lived boundary that must handle security, compatibility, failure, performance, observability, and evolution.
Key Takeaway: APIs should be designed as stable production contracts: clear boundaries, predictable responses, safe errors, explicit security, controlled retries, useful observability, and backward-compatible evolution.
More Articles to Read
Continue with API articles that go deeper into REST design, authentication, rate limiting, pagination, versioning, and reliability.
Comments (0)