Jedes Team, mit dem ich gearbeitet habe, erfindet irgendwann sein eigenes API-Response-Format. Erst sieht es harmlos aus —
ein kleines Wrapper-Objekt hier, ein custom Error-Format dort — und sechs Monate später schreibst du die vierte
Version deiner Error-Parsing-Middleware und diskutierst im Code Review, ob data.user oder
data.result.user der "richtige" Pfad ist. Es gibt keinen universellen Standard, der all das löst, aber
es gibt Patterns, die sich in Produktion bewähren, und Anti-Patterns, die dich garantiert noch beißen werden.
Hier ist, was ich tatsächlich in ein Design-Doc schreiben würde.
Konsistente Erfolgs-Responses
Die erste Frage, die jedes Team debattiert: soll jede Response in einen Envelope wie
{"status": "ok", "data": {...}} gewickelt werden? Die ehrliche Antwort ist — wahrscheinlich nicht per default. Envelopes
machten mehr Sinn in den frühen 2000ern, als HTTP-Statuscodes über Proxies und
mobile Netze nicht immer verlässlich waren. Heute ist eine flache Response, die die Ressource für sich selbst sprechen lässt, fast immer sauberer.
Reserviere den Envelope für Endpoints, die wirklich gemischte Payloads zurückgeben, wie eine Bulk-Operation, die
teilweise gelingt.
// ✅ Good — flat, direct, the order IS the response
// GET /v1/orders/ord_9kZ2m
{
"id": "ord_9kZ2m",
"status": "fulfilled",
"customer_id": "cus_4xA1p",
"total_amount": 149.99,
"currency": "USD",
"created_at": "2026-03-15T11:42:00Z",
"line_items": [
{ "sku": "HDPHN-BLK-XM5", "quantity": 1, "unit_price": 149.99 }
]
}
// ❌ Avoid — unnecessary envelope adds a layer clients have to unwrap every time
{
"status": "success",
"code": 200,
"data": {
"order": {
"id": "ord_9kZ2m"
}
}
}Wrapping ergibt Sinn, wenn du Metadaten co-lokalisieren musst, die nicht Teil der Ressource selbst sind —
Pagination-Cursor, Request-IDs für Tracing, oder Partial-Failure-Summaries in Bulk-Endpoints. Für ein einfaches
GET /orders/:id ist die Order die Response. Lass Clients nicht
response.data.order.id schreiben, wenn response.id einfach funktioniert. Wenn du eine Spec
als Referenz willst, JSON:API ist ein meinungsstarker,
aber gut durchdachter Standard, der genau definiert, wann und wie Envelopes zu nutzen sind — lesenswert, auch wenn
du ihn nicht komplett übernimmst.
Error-Responses — nutz RFC 7807 Problem Details
Custom Error-Formate sind eine der häufigsten Quellen für Integrationsschmerzen. Jede API landet bei
etwas leicht Anderem — {"error": "..."}, {"message": "...", "code": 42},
{"errors": [...]} — und jeder Client, der deine API konsumiert, muss maßgeschneiderte Error-Parsing-
Logik schreiben. Die IETF hat das mit
RFC 7807 — Problem Details for HTTP APIs
gelöst. Es ist ein leichtgewichtiger Standard, der eine konsistente JSON-Struktur für Fehler definiert, mit einem
Content-Type von application/problem+json. Übernimm ihn, und dein Error-Format wird
etwas, das jeder Dev ohne Doku-Suche lesen kann.
// POST /v1/orders — 422 Unprocessable Entity
// Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The order could not be created because one or more fields are invalid.",
"instance": "/v1/orders/requests/req_7bN3k",
"errors": [
{
"field": "line_items[0].quantity",
"message": "Quantity must be a positive integer."
},
{
"field": "shipping_address.postal_code",
"message": "Postal code is required for US shipments."
}
]
}- Vorhersehbares Parsing: Clients wissen immer, wo sie die menschenlesbare Nachricht (
detail), die maschinenlesbare Kategorie (type) und den im Body gespiegelten HTTP-Status (status) finden. - Design-bedingt erweiterbar: Die Spec erlaubt explizit zusätzliche Felder wie
errorsfür Feld-Level-Validierungsdetails — du umgehst nichts. - Tooling-Support: OpenAPI 3.x unterstützt
application/problem+jsonals Response-Content-Type, also verstehen deine generierte Doku und Client-SDKs das Format nativ. - Die
type-URI ist ein Dokument, nicht nur ein String: Lass sie auf eine echte Seite zeigen, die den Fehler erklärt, und du hast gerade ein Support-Ticket durch eine Self-Service-Antwort ersetzt.
HTTP-Statuscodes + JSON-Body zusammen
Der Statuscode und der JSON-Body sind nicht redundant — sie spielen unterschiedliche Rollen. Der Statuscode sagt der HTTP-Schicht (Proxies, Caches, Browser, Monitoring-Tools), was passiert ist. Der JSON-Body sagt es deiner Applikationsschicht. Beide müssen stimmen. MDNs HTTP-Status-Referenz ist der schnellste Weg, Debatten darüber zu klären, welcher Code passt. Die, die Teams am häufigsten verwirren, sind 400 vs 422 (beides Client-Fehler, aber 422 heißt speziell, dass die Syntax valide war und der Server sie verstanden hat — die Semantik war falsch) und 401 vs 403 (401 heißt "wer bist du?", 403 heißt "ich weiß, wer du bist — du darfst das nicht tun").
// 400 Bad Request — malformed JSON or missing required field at the HTTP level
{
"type": "https://api.example.com/problems/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "Request body is not valid JSON."
}
// 422 Unprocessable Entity — valid JSON, but business rules rejected it
{
"type": "https://api.example.com/problems/insufficient-inventory",
"title": "Insufficient Inventory",
"status": 422,
"detail": "HDPHN-BLK-XM5 has 0 units available; requested 2.",
"instance": "/v1/orders/requests/req_7bN3k"
}
// 404 Not Found — resource doesn't exist (or you don't want to reveal it does)
{
"type": "https://api.example.com/problems/not-found",
"title": "Order Not Found",
"status": 404,
"detail": "No order with ID ord_XXXXX exists in this account."
}- 200 OK — erfolgreiches GET, PUT, PATCH, das einen Body zurückgibt
- 201 Created — erfolgreiches POST, das eine Ressource erstellt hat; füg einen
Location-Header hinzu, der auf die neue Ressource zeigt - 204 No Content — erfolgreiches DELETE oder Aktion ohne Response-Body; kein JSON nötig
- 400 Bad Request — malformed Request-Syntax, der Server kann sie nicht mal parsen
- 401 Unauthorized — fehlende oder invalide Authentifizierungs-Credentials
- 403 Forbidden — authentifiziert, aber nicht erlaubt
- 404 Not Found — Ressource existiert nicht
- 409 Conflict — State-Konflikt (z.B. doppelte Order, Optimistic-Lock-Fehler)
- 422 Unprocessable Entity — valide Syntax, fehlgeschlagene semantische/Business-Validierung
- 429 Too Many Requests — Rate-Limit erreicht; füg immer einen
Retry-After-Header hinzu - 500 Internal Server Error — irgendwas auf Serverseite ist kaputt; leak nie Stack Traces im Body
Datum und Uhrzeit — immer ISO 8601
Unix-Timestamps sehen sauber aus — einfach eine Zahl. Aber sie sind eine Falle. Ist 1710499200 Sekunden
oder Millisekunden? (Beides ist üblich. JavaScripts Date.now() liefert Millisekunden, POSIX liefert
Sekunden.) Welche Zeitzone? Sie sind in Logs ohne Konverter unlesbar. Sie können Daten vor
1970 nicht sauber darstellen. Und sie werden 2038 auf Systemen, die noch nicht migriert sind, 32-Bit-Integer überlaufen.
ISO 8601-Strings lösen
all das. Nutz UTC und füg immer den Timezone-Offset hinzu — ein nacktes 2026-03-15T11:42:00
ohne abschließendes Z oder +00:00 ist zweideutig und wird irgendwann einen Bug in
einem Client verursachen, der Ortszeit annimmt.
// ✅ Good — unambiguous, human-readable, timezone-explicit
{
"created_at": "2026-03-15T11:42:00Z",
"updated_at": "2026-04-01T08:15:33Z",
"scheduled_delivery": "2026-03-18T00:00:00Z",
"expires_at": "2026-04-15T23:59:59Z"
}
// ❌ Avoid — ambiguous, unreadable, seconds vs ms confusion
{
"created_at": 1710499200,
"updated_at": 1743494133000,
"scheduled_delivery": "15/03/2026",
"expires_at": "April 15, 2026"
}Null vs. weggelassene Felder
Diese beiden sind nicht dasselbe, und sie zu vermischen führt zu subtilen Bugs, die nur in Edge Cases auftauchen.
Null heißt, das Feld existiert, der Server kennt es, und sein aktueller Wert ist "nichts" —
wie ein fulfilled_at-Timestamp auf einer Order, die noch nicht verschickt wurde.
Ein Feld komplett weglassen heißt, es gilt in diesem Kontext nicht — wie ein
return_tracking_number auf einer nicht zurückgegebenen Order. Wenn ein Client "fulfilled_at": null sieht,
weiß er, das Feld ist Teil des Schemas dieser Ressource und explizit ungesetzt. Ist das Feld abwesend, sollte der
Client es als außerhalb des Scopes dieser Response behandeln — wichtig, wenn du Partial-Updates
mit PATCH machst. null zu schicken heißt "räum das Feld", es wegzulassen heißt "fass es nicht an".
// Order that exists but hasn't shipped yet
// fulfilled_at: null — we know about this field, it's just not set yet
// return_tracking_number: omitted — returns don't apply to this order
{
"id": "ord_9kZ2m",
"status": "processing",
"created_at": "2026-03-15T11:42:00Z",
"fulfilled_at": null,
"shipped_at": null,
"tracking_number": null,
"total_amount": 149.99
}
// PATCH /v1/orders/ord_9kZ2m — cancel the scheduled delivery
// Only include fields you want to change
{
"scheduled_delivery": null,
"status": "cancelled"
}
// "total_amount" is omitted — we're NOT zeroing it out, just not touching itPagination — Cursor statt Offset
Offset-Pagination (?page=3&per_page=20) ist intuitiv zu implementieren und leicht zu erklären,
aber sie bricht still bei Live-Daten. Wenn ein Record eingefügt wird, während ein Client paginiert — zwischen Seite 2
und Seite 3 — überspringt er ein Item. Wenn ein Record gelöscht wird, sieht er einen Duplikat. Für jeden Datensatz, der
sich häufig ändert (Orders, Events, Notifications), ist Cursor-basierte Pagination der richtige Default. Du gibst
dem Client einen opaken Cursor (typischerweise eine base64-codierte ID oder ein Timestamp), der seine Position im
Ergebnis-Set repräsentiert. Die nächste Seite startet an genau diesem Punkt, unabhängig von Inserts oder Deletes. Offset-
Pagination ist ok für Admin-UIs, wo der Datensatz stabil ist und Nutzer wirklich auf Seite 47 springen müssen.
Sie ist nicht ok für irgendeinen Mobile-Client mit Infinite Scroll.
// GET /v1/orders?limit=20&cursor=eyJpZCI6Im9yZF85a1oybSJ9
{
"orders": [
{ "id": "ord_9kZ2m", "status": "fulfilled", "total_amount": 149.99, "created_at": "2026-03-15T11:42:00Z" },
{ "id": "ord_8jY1l", "status": "processing", "total_amount": 89.00, "created_at": "2026-03-14T09:10:00Z" }
],
"pagination": {
"next_cursor": "eyJpZCI6Im9yZF84alk1bCJ9",
"has_more": true,
"limit": 20
}
}
// When has_more is false, omit next_cursor entirely (or set to null)
// Clients: fetch next page with ?cursor=<next_cursor> until has_more === falseFeldbenennung — snake_case vs camelCase
Wähl eine Konvention und erzwing sie mit einem Linter. Die konkrete Wahl zählt weniger als die
Konsistenz. Dennoch: Wenn deine primären Konsumenten JavaScript-/TypeScript-Clients sind,
integriert sich camelCase sauber mit Destructuring und Object-Spread.
Wenn deine primären Konsumenten Python- oder Ruby-Backends sind, fühlt sich snake_case natürlich an.
Wenn du beide bedienst, ist die pragmatische Lösung, die Konvention zu dokumentieren und Clients eine
Transformationsschicht nutzen zu lassen — JSON.parse
mit einem Reviver, eine Python-humps-Library, oder eine einzige Serialisierungs-Config in deinem Framework.
Was du nie tun solltest, ist Konventionen in derselben API mischen — customerId neben
order_total ist ein Zeichen, dass verschiedene Entwickler unterschiedliche Endpoints geschrieben haben, ohne miteinander
zu reden. Nutz den JSON Schema Generator, um deine Feldnamen
konsistent über Endpoints hinweg zu dokumentieren.
Versionierung
Zwei Schulen: URL-Versionierung (/v1/orders, /v2/orders) und Header-Versionierung
(Accept: application/vnd.example.v2+json oder ein custom API-Version: 2026-03-15-Header).
URL-Versionierung gewinnt in der Praxis fast immer. Sie ist in Logs ohne Header-Parsing sichtbar,
sie funktioniert mit jedem HTTP-Client ohne Konfiguration, du kannst sie im Browser testen, und du kannst v1
und v2 nebeneinander im selben Gateway mit einer simplen Path-Präfix-Regel laufen lassen. Header-Versionierung ist theoretisch
RESTful-er laut dem
IANA-Media-Type-Modell,
aber sie erzeugt unsichtbare Komplexität — ein Request, der in der URL identisch aussieht, verhält sich tatsächlich
unterschiedlich, abhängig von einem Header, den die meisten Entwickler nicht zuerst prüfen.
Stripes datumsbasierte Versionierung (Stripe-Version: 2024-06-20) ist das Beste aus beiden Welten für
große Plattformen, aber das ist ein anderes Problem als die Wahl deines ersten Versionierungsschemas.
Was auch immer du wählst, versioniere ab Tag eins. Versionierung nachträglich in eine unversionierte API in Produktion zu
retrofit ist schmerzhaft und geht selten sauber aus. Nutz den JSON Validator, um zu bestätigen, dass
Responses aus beiden API-Versionen während Migrationstests strukturell solide sind.
Fazit
Nichts davon ist bahnbrechend — aber genau das ist der Punkt. Die Teams, die am meisten mit API- Design kämpfen, sind nicht die, die technisch falsche Entscheidungen getroffen haben. Es sind die, die unterschiedliche Entscheidungen in unterschiedlichen Endpoints getroffen und sie nie aufgeschrieben haben. Flache Erfolgs-Responses. RFC-7807-Error-Bodies. ISO-8601- Datums. Cursor-Pagination bei Live-Daten. Null für "bekannt und leer", weggelassen für "gilt nicht". URL- Versionierung ab Tag eins. Diese Patterns sind nicht perfekt, aber sie sind vorhersehbar — und Vorhersehbarkeit ist das, was eine API zu einem Vergnügen macht, mit dem man integriert, statt zu einem Puzzle, das man reverse-engineered. Die formale JSON- Spezifikation lebt in RFC 8259, wenn du mal ein Argument auf Spec-Level klären musst. Für alles darüber ist der beste Standard der, den dein Team tatsächlich aufschreibt und konsistent befolgt.