HTTP APIs Explained Like You’re Building a Real Backend

Real backend engineers design them as contracts not as endpoints.

Press enter or click to view image in full size

Most developers learn APIs like this –

GET means read.
POST means create.
PUT means update.
DELETE means delete.

Cool.

That is useful for the first 30 minutes.

But real backend APIs are not difficult because people forget what GET means. They are difficult because users double-click, mobile networks retry, payments complete late, old app versions keep calling old contracts, filters destroy database performance, and one badly designed response becomes a promise you cannot break for the next three years.

In tutorials, APIs look clean.

In real life, APIs are where product requirements, databases, security, performance, mobile apps, retries, payments, and angry users meet for a group fight.

So let’s skip the boring textbook version and talk about HTTP APIs like we are actually building a backend that has to survive.

An API is not just an endpoint. It is a contract.

A lot of people think API design means naming endpoints.

Something like –

GET /orders
POST /orders
GET /orders/{orderId}

That is only the surface.

An API is a contract between systems.

The frontend depends on it.
The mobile app depends on it.
Other backend services may depend on it.
Partners may depend on it.
Old app versions may keep depending on it long after you forgot it exists.

That means changing an API is not like changing a private function inside your codebase.

If you rename an internal function, your compiler or tests might catch the break.

If you rename an API field used by an old Android app, congratulations, you have just created a bug that lives in thousands of phones.

This is why good API design starts with a boring but powerful question –

“Can clients safely depend on this for a long time ?”

Because once your API is used, it becomes a promise.

And software promises are expensive to break.

Start with workflow, not methods

Press enter or click to view image in full size

The beginner way to design APIs is –

“What CRUD endpoints do I need ?”

The better way is –

“What workflow is the user or system trying to complete ?”

Let’s say we are building an order flow.

The user may –

Search for items.
View details.
Add item to cart.
Create order.
Start payment.
Wait for payment confirmation.
View final status.
Cancel or refund if needed.

Now APIs naturally come out of the workflow –

GET /items?query=headphones
GET /items/{itemId}
POST /cart/items
POST /orders
POST /orders/{orderId}/payment
GET /orders/{orderId}
POST /orders/{orderId}/cancel

This is already better than randomly creating endpoints like –

POST /createOrder
POST /makePayment
POST /doCancel
POST /getOrderDetails

The API should reflect the business flow clearly.

Good APIs are not just technically correct. They are understandable.

A new engineer should be able to look at your APIs and roughly understand how the product works.

Not everything should be forced into CRUD

CRUD is useful.

But real products have business actions.

For example

POST /orders/{orderId}/cancel
POST /payments/{paymentId}/refund
POST /bookings/{bookingId}/confirm
POST /users/{userId}/block

Some people try to force every action into PATCH.

For example

PATCH /orders/{orderId}
{
"status": "CANCELLED"
}

Looks clean, right ?

But it hides business meaning.

Cancelling an order may need to check whether the order has shipped, release inventory, start refund, notify seller, update invoice, and publish an event. It is not just “update status.”

So this is often better –

POST /orders/{orderId}/cancel

Now the API says what the user is trying to do.

The backend can own the business logic.

This is an important design lesson –

If an operation has business rules, model it as a business action, not just a field update.

CRUD is good for simple resources.

Business actions deserve explicit APIs.

Your API should not trust the frontend

Frontend validation is for user experience.

Backend validation is for survival.

The frontend may send –

{
"quantity": -10
}

Or my personal favorite –

{
"couponCode": "FREE_EVERYTHING_FOREVER"
}

Never assume the client is honest.

The client is outside your trust boundary. Users can inspect requests, modify payloads, replay calls, automate flows, or use old app versions.

So the backend must validate –

Required fields.
Data types.
Allowed values.
String lengths.
Numeric ranges.
Permissions.
Business rules.
Server-side price calculation.
Ownership of resources.

If the client sends a price, treat it as decorative.

The backend should calculate the real price using product data, discount rules, taxes, delivery fees, and whatever business logic applies.

A good API accepts user intent.

It does not blindly accept user truth.

Idempotency is where APIs become serious

This is one of the most important API concepts people hear late.

Imagine a user clicks Pay.

The request goes to your backend.

Your backend calls the payment provider.

The payment succeeds.

But before the response reaches the user’s phone, the network drops.

Now the app thinks

“Maybe payment failed. Let me retry.”

Without protection, the second request may charge the user again.

Now the user is angry, support team is crying, and the backend engineer is pretending to be offline on Slack.

This is where idempotency comes in.

Idempotency means

Repeating the same request should not repeat the dangerous side effect.

For critical APIs, the client sends an idempotency key –

POST /payments
Idempotency-Key: pay_req_123
{
"orderId": "ord_456",
"amount": 2999
}

The backend stores this key with the result.

If the same request comes again with the same key, the backend does not create a new payment. It returns the previous result.

Something like –

{
"paymentId": "pay_789",
"status": "PROCESSING"
}

This protects against –

Double clicks.
Network retries.
Mobile app timeouts.
Browser refreshes.
Client uncertainty.
Duplicate payment attempts.
Duplicate order creation.

Important detail people miss –

The idempotency key should usually be scoped.

For example

user_id + idempotency_key

Otherwise, two different users accidentally using the same key could conflict.

Also, idempotency records should not live forever. Usually, they need an expiry window depending on the business flow.

For payments, you may keep them longer.

For simple form submissions, shorter may be fine.

The key idea –

Any API that creates money movement, orders, bookings, rewards, inventory changes, or irreversible actions should think about idempotency.

If your API cannot safely handle retries, it is not production-ready.

Your response should reflect reality, not optimism

A common bad habit is returning success too early.

For example

{
"status": "SUCCESS",
"message": "Payment successful"
}

But what if the payment is not actually confirmed yet ?

Many real flows are not instant.

Payments may be pending.
Bank confirmation may arrive later.
External provider may call your webhook after some time.
Inventory reservation may expire.
Background processing may still be running.

So your API should say what is truly known.

Better –

{
"orderId": "ord_123",
"status": "PENDING_PAYMENT",
"paymentUrl": "https://provider.example/pay/pay_456"
}

Or

{
"jobId": "job_123",
"status": "PROCESSING"
}

The API should not lie just to make the UI happy.

If something is pending, say pending.

If something is accepted but not completed, return that.

This is where 202 Accepted becomes useful.

202 Accepted

It means –

“I accepted your request, but the work is not finished yet.”

Useful for –

Video processing.
Report generation.
Bulk upload.
Payment initiation.
Long-running workflows.
Async fraud checks.
Document verification.

A strong API tells the client the current truth and gives a way to check the final truth.

For example –

GET /jobs/{jobId}
GET /orders/{orderId}
GET /payments/{paymentId}

This makes the system much easier to reason about.

Use status codes to communicate meaning

You do not need to memorize every HTTP status code.

But you should use the important ones correctly.

For invalid input –

400 Bad Request

For unauthenticated user –

401 Unauthorized

Meaning –

“Please login.”

For authenticated but not allowed –

403 Forbidden

Meaning –

“I know who you are, but you cannot do this.”

For missing resource –

404 Not Found

For state conflict –

409 Conflict

This is very useful and underused.

Examples

Trying to book something already taken.
Trying to confirm an already cancelled order.
Trying to update a stale version of a resource.
Trying to create a username that already exists.

For rate limiting –

429 Too Many Requests

Meaning

“Calm down bro.”

For async accepted –

202 Accepted

For unexpected server failure –

500 Internal Server Error

The mistake many teams make is returning 200 OK for everything.

Bad –

200 OK
{
"success": false,
"message": "Order not found"
}

Better –

404 Not Found
{
"error": {
"code": "ORDER_NOT_FOUND",
"message": "Order not found"
}
}

Correct status codes help clients behave correctly.

They also help observability.

If all errors are 200, your dashboards will say everything is fine while users are suffering.

That is not monitoring.

That is denial.

Error responses should be designed, not thrown

A good API error response should be predictable.

Bad –

{
"message": "Something went wrong"
}

Worse –

{
"error": "NullPointerException at line 47"
}

Please do not leak internal stack traces to clients.

A better error response –

{
"error": {
"code": "ORDER_ALREADY_CONFIRMED",
"message": "This order is already confirmed.",
"requestId": "req_abc123"
}
}

Why is this better ?

Get Mridul Singh’s stories in your inbox

Join Medium for free to get updates from this writer.

The code is machine-readable.
The message is human-readable.
The requestId helps debugging.

Frontend can handle specific error codes.

For example –

PAYMENT_FAILED -> show retry payment button
ORDER_EXPIRED -> show create new order button
RATE_LIMITED -> show wait message
UNAUTHORIZED -> redirect to login

Without structured errors, the frontend ends up parsing random strings.

That is how bugs are born.

A good API error should answer –

What failed ?
Can the client fix it ?
Should the user retry ?
Is this permanent or temporary ?
How can engineers trace it ?

Pagination is not just a frontend feature

This API looks harmless –

GET /orders

Until one user has 50 orders, one admin has 5 million orders, and one data export script calls it during peak traffic.

Large lists need pagination.

The two common styles are offset and cursor.

Offset pagination –

GET /orders?page=3&pageSize=20

It is simple and works for small or admin-style lists.

But for large, frequently changing data, it can become slow or inconsistent.

Cursor pagination –

GET /orders?limit=20&cursor=abc123

This is better for feeds, notifications, messages, activity logs, and large ordered lists.

The server returns –

{
"items": [...],
"nextCursor": "def456"
}

Important thing people miss –

Pagination is a database design decision, not just an API design decision.

If you allow –

GET /orders?status=delivered&sort=created_at_desc

Your database may need an index that supports that query.

If you allow ten filters and five sort options, you may be creating fifty query patterns.

Every filter you expose publicly becomes a performance promise.

So before adding filters casually, ask –

Can the database support this efficiently ?
Do we need an index ?
Will this work at 10 million rows ?
Should this be powered by a search engine instead ?
Do we need cursor pagination ?
Can users sort by this field safely ?

APIs and databases are connected.

A bad API can force a database into pain.

And databases do not suffer silently. They take the whole app down with them.

Partial responses: return what clients need, not your entire soul

Another common mistake is returning too much data.

Example –

{
"id": "user_123",
"name": "Aarav",
"email": "aarav@example.com",
"passwordHash": "...",
"internalRiskScore": 87,
"adminNotes": "flagged once",
"createdByAdminId": "admin_456"
}

This is not an API response.

This is a data leak wearing JSON clothes.

Return only what the client needs.

This improves –

Security.
Performance.
Network usage.
Client simplicity.
Backward compatibility.

For list APIs, keep responses lightweight.

For detail APIs, return richer data.

Example

GET /orders

Can return summary fields

GET /orders/{orderId}

Can return full order details.

This prevents list screens from downloading the entire universe.

Versioning is not only /v1

People think API versioning means adding this –

/api/v1/orders

That is one part.

But real backward compatibility is deeper.

Suppose your API returns –

{
"status": "CONFIRMED"
}

Later, you add new statuses –

PARTIALLY_CONFIRMED
PAYMENT_PENDING
EXPIRED

Will old clients handle them ?

Maybe not.

Suppose your app expects –

{
"amount": 2999
}

And you change it to –

{
"amount": {
"value": 2999,
"currency": "INR"
}
}

That may break clients.

Mobile apps are especially painful because old versions live for months.

Safe API changes –

Adding optional fields.
Adding new endpoints.
Adding new query parameters with defaults.
Adding new error codes carefully.
Keeping old fields while introducing new ones.

Dangerous changes –

Removing fields.
Renaming fields.
Changing data types.
Changing enum meanings.
Changing response shape.
Changing pagination behavior.
Changing error format.

A mature backend team treats API changes carefully.

Because somewhere, somehow, an old app version is still calling your API from a phone with 3% battery and full confidence.

Authentication is not enough. Authorization matters more.

Authentication means –

“Who are you ?”

Authorization means –

“Are you allowed to do this ?”

A user may be logged in but still not allowed to access another user’s order.

GET /orders/ord_999

The backend must check –

Does this order belong to this user ?
Is this user an admin ?
Is this action allowed in current state ?
Is this service allowed to call this internal API ?

Do not rely only on frontend hiding buttons.

Frontend hiding a button is not security.

Users can still call APIs manually.

Also, do not rely only on API Gateway authentication.

The gateway may verify the token, but the service should still enforce resource-level permissions.

A good system has defense in depth.

The front door has a lock.

The rooms with valuables also have locks.

APIs should be designed for retries

Networks fail.

Mobile networks fail more.

External services timeout.

Clients retry.

Gateways retry.

Message consumers retry.

If your API is not designed for retries, production will teach you personally.

For read APIs, retries are usually safe.

For write APIs, retries can be dangerous.

This is why you need –

Idempotency keys for critical writes.
Clear timeout behavior.
Safe duplicate handling.
State checks before transitions.
Unique constraints where needed.
Request IDs for tracing.
Retryable vs non-retryable error distinction.

For example, if payment initiation times out, the client should not blindly create a new payment every time.

It should retry with the same idempotency key or check payment status.

A good API answers –

“What should the client do if it does not know whether the request succeeded ?”

Most bad APIs ignore this question.

Then users get duplicate orders, duplicate payments, and duplicate headaches.

Sync or async: do not make users wait for your side quests

Some work must happen before responding.

Example –

Validate order.
Reserve inventory.
Create payment session.
Write primary record.

Some work can happen later.

Example –

Send email.
Generate invoice.
Update analytics.
Sync to data warehouse.
Send push notification.
Update recommendation model.

Do not block the user on everything.

For long-running work, use async APIs.

Example –

POST /reports

Response –

202 Accepted
{
"reportId": "rep_123",
"status": "PROCESSING"
}

Then –

GET /reports/rep_123

returns –

{
"reportId": "rep_123",
"status": "COMPLETED",
"downloadUrl": "https://..."
}

This is better than making the request hang for two minutes.

Async APIs make systems more reliable, but they require good state design.

The client needs to know –

Is it pending ?
Is it processing ?
Did it fail ?
Can it retry ?
Where can it check status ?

A button click may start a workflow.

It does not always finish it immediately.

API observability: your future debugging depends on it

Every important API should be traceable.

At minimum, think about –

Request ID.
User ID if authenticated.
Endpoint name.
Status code.
Latency.
Error code.
Downstream calls.
Retry count.
Idempotency key for critical flows.

When a user says –

“My payment failed.”

You should be able to search by request ID, user ID, order ID, or payment ID and follow the flow.

Without observability, debugging becomes –

“Can you send a screen recording ?”

That is not engineering.

That is astrology with logs.

A good API is not just designed for happy-path usage.

It is designed for debugging when things go wrong.

A practical checklist for real API design

Before shipping an API, ask –

What workflow does this API belong to ?
Is this a resource operation or a business action ?
Who is allowed to call it ?
What should the backend validate ?
Can this request be retried safely ?
Does it need an idempotency key ?
What happens if the client times out ?
What status codes can it return ?
Are errors structured and predictable ?
Does it need pagination ?
Can the database support the filters and sorting ?
Are we exposing too much data ?
Will old app versions break if this changes ?
Should this be synchronous or asynchronous ?
Can we trace this request in logs and metrics ?

This is the difference between “I created an endpoint” and “I designed an API”

Common mistakes that look harmless at first

Returning 200 OK for every error.

Using POST for everything because it is easy.

Letting clients send trusted fields like price, role, status, or discount.

No idempotency on payment/order/booking APIs.

No pagination on list APIs.

Allowing filters that the database cannot support.

Returning internal fields in public responses.

Changing response structure without thinking about old clients.

Using vague errors like “Something went wrong.”

Making long-running APIs synchronous.

Depending only on frontend validation.

Treating API Gateway auth as enough security.

Not logging request IDs.

Each of these looks small.

Each can become a production issue.

The final mental model

An HTTP API is not just a URL with a method.

It is a contract, a workflow boundary, a security boundary, a performance promise, and sometimes a financial/legal responsibility.

Good APIs are boring in the best way.

They are predictable.
They validate carefully.
They return meaningful statuses.
They handle retries safely.
They use idempotency where needed.
They expose only what clients need.
They support pagination properly.
They are backward-compatible.
They make async work explicit.
They are observable when things break.

The beginner question is –

“What endpoint should I create ?”

The real backend question is –

What promise am I making, and what can go wrong when this API is used at scale ?”

That is API design.

Not just GET and POST.

Not just pretty routes.

A good API is a promise between systems.

And like all promises in software, breaking it will hurt someone.

Usually frontend first.

Then backend.

Then on-call.

Then everyone.

Similar Posts

Leave a Reply