hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses REST API Design with @hectoday/http

What Makes an API RESTful

  • APIs are contracts
  • Project setup
  • Resources, not actions

HTTP Methods

  • GET, POST, PUT, PATCH, DELETE
  • Idempotency
  • Method safety and side effects

Status Codes

  • The status codes that matter
  • Error responses

Resource Design

  • Modeling resources
  • Partial responses and field selection
  • Pagination
  • Filtering, sorting, and searching

API Lifecycle

  • Versioning
  • Content negotiation
  • Rate limiting and quotas

Advanced Patterns

  • Bulk operations
  • Long-running operations
  • HATEOAS and discoverability

Putting It All Together

  • API design checklist
  • Summary

Content negotiation

How clients and servers agree on format

Every HTTP request and response carries data in some format. Usually it’s JSON. But sometimes a client needs CSV to import into a spreadsheet, or you need to verify that the client is actually sending JSON and not something else. The mechanism for this is called content negotiation, and it’s built into HTTP through two headers.

Content-Type and Accept

Content-Type on a request tells the server what format the request body is in. When a client sends Content-Type: application/json, it’s saying “the body I’m sending you is JSON.”

Accept on a request tells the server what format the client wants the response in. When a client sends Accept: application/json, it’s saying “please send me JSON back.”

Content-Type on the response tells the client what format the response body is in. When Response.json() sends data, it automatically sets Content-Type: application/json.

JSON is the default

For REST APIs, JSON is the standard. If no Accept header is sent, return JSON. Most clients assume JSON. Most tooling expects JSON. Unless you have a specific reason to support other formats, JSON is all you need.

import { books } from "../db.js";

route.get("/books/:id", {
  request: { params: z.object({ id: z.string() }) },
  resolve: (c) => {
    if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
    const { id } = c.input.params;
    const book = books.find((b) => b.id === id);
    if (!book) return notFound("Book");
    return Response.json(book); // Content-Type: application/json
  },
});

That’s it. For many APIs, content negotiation starts and ends with “return JSON.”

Supporting multiple formats

Sometimes you need more than JSON. The most common case is CSV export. Users want to download data into a spreadsheet. Let’s add CSV support to our books endpoint:

import { books } from "../db.js";

route.get("/books", {
  resolve: (c) => {
    const accept = c.request.headers.get("accept") ?? "application/json";

    if (accept.includes("text/csv")) {
      const csv = booksToCSV(books);
      return new Response(csv, {
        headers: { "content-type": "text/csv" },
      });
    }

    // Default: JSON
    return Response.json({ data: books });
  },
});

function booksToCSV(books: any[]): string {
  const header = "id,title,genre,publishedAt,authorId";
  const rows = books.map(
    (b) => `${b.id},"${b.title}",${b.genre},${b.publishedAt ?? ""},${b.authorId}`,
  );
  return [header, ...rows].join("\n");
}

The server checks the Accept header. If the client wants CSV, it converts the books to CSV format and sets the response Content-Type to text/csv. Otherwise, it returns JSON as usual.

The client controls this entirely through the Accept header:

# Get JSON (default)
curl http://localhost:3000/books

# Get CSV
curl -H "Accept: text/csv" http://localhost:3000/books

406 Not Acceptable

What if the client requests a format you don’t support? Like XML?

if (
  !accept.includes("application/json") &&
  !accept.includes("text/csv") &&
  !accept.includes("*/*")
) {
  return apiError(406, "NOT_ACCEPTABLE", "Supported formats: application/json, text/csv");
}

406 Not Acceptable means “I can’t produce a response in any format you’ll accept.” The response should tell the client which formats are available.

In practice, if your API only supports JSON, you don’t need to check the Accept header at all. Just return JSON. The 406 check is only worth adding when you support multiple formats and want to be explicit about what’s available.

Validating request body format

On the flip side, your server should verify that incoming request bodies are in the right format. If an endpoint expects JSON but receives form data or plain text, that’s a problem:

const contentType = c.request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
  return apiError(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json");
}

415 Unsupported Media Type means “I can’t process the format you sent me.”

[!NOTE] Hectoday HTTP’s Zod body validation handles this implicitly. If the body can’t be parsed as JSON, the validation fails with a clear error. You only need explicit Content-Type checking if you want to return a more specific 415 error message before attempting to parse.

When to support multiple formats

Support JSON only for most APIs. JSON is universal, every language has a parser for it, and every developer expects it.

Add CSV for data export endpoints. When users need to pull data into spreadsheets or import it into other tools, CSV is the right choice.

Add XML only for legacy integrations. Some enterprise systems only speak XML. But don’t add XML support “just in case.” Wait until someone actually needs it.

What’s next

We’ve been building features: filtering, pagination, field selection, content negotiation. But we haven’t thought about protecting the API from abuse. What happens if one client sends thousands of requests per second? That’s where rate limiting comes in.

Exercises

Exercise 1: Add CSV support to GET /books. Test with curl -H "Accept: text/csv" http://localhost:3000/books.

Exercise 2: Add a 406 response when the client requests an unsupported format (like Accept: text/xml).

Exercise 3: Add Content-Type validation to POST endpoints. Return 415 for non-JSON request bodies.

What should an API return when the client sends Accept: text/xml but the API only supports JSON?

← Versioning Rate limiting and quotas →

© 2026 hectoday. All rights reserved.