Payloads and Responses
Standard request/response shapes for common REST operations. Conventions chosen to be sensible defaults for both public-facing APIs and internal microservices. All examples use a fictional users and orders resource.
Conventions used throughout
Section titled “Conventions used throughout”- Field names:
snake_casein JSON (matches Python idiom; Go can serialize either via tags) - Timestamps: RFC 3339 / ISO 8601 with timezone —
2026-05-06T14:32:10Z - IDs: UUIDv7 for resources (sortable + globally unique); strings, not ints
- Money: minor units as integer (
"amount_cents": 1999) + ISO 4217 currency code - Enums: lowercase strings with underscores (
"status": "in_progress") - Nullability: explicit
nullover field omission; document which fields can be null - Unknown fields: clients ignore; servers reject (strict by default for security)
Standard envelopes
Section titled “Standard envelopes”Success — single resource
Section titled “Success — single resource”{ "data": { "id": "01933f8a-...", "type": "user", "..." }}Success — collection (paginated)
Section titled “Success — collection (paginated)”{ "data": [ { ... }, { ... } ], "pagination": { "next_cursor": "eyJpZCI6Ii4uLiJ9", "has_more": true, "limit": 50 }}{ "error": { "code": "VALIDATION_FAILED", "message": "Email address is invalid", "details": { "fields": { "email": "must be a valid email address" } }, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" }}Note: Some teams prefer “bare” responses (no
datawrapper) for single-resource endpoints. Pick one and be consistent. The wrapper makes it easier to add metadata (meta,links) later without breaking changes.
POST — Create a resource
Section titled “POST — Create a resource”Request
Section titled “Request”POST /v1/users HTTP/1.1Content-Type: application/jsonAuthorization: Bearer eyJhbGc...Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7X-Request-ID: 9b2f...
{ "name": "Atif", "role": "engineer", "metadata": { "team": "sre", "location": "livermore" }}Why these headers:
Idempotency-Key— client-generated UUID. Server stores(key, principal) → responsefor 24h. Retries return the same response without creating duplicates. Critical for POST/charge operations.X-Request-ID— client-supplied or server-generated. Propagated through logs and downstream calls for tracing.
Response — 201 Created
Section titled “Response — 201 Created”HTTP/1.1 201 CreatedContent-Type: application/jsonLocation: /v1/users/01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6bX-Request-ID: 9b2f...
{ "data": { "id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b", "name": "Atif", "role": "engineer", "status": "active", "metadata": { "team": "sre", "location": "livermore" }, "created_at": "2026-05-06T14:32:10Z", "updated_at": "2026-05-06T14:32:10Z" }}Key points:
- Return the full created resource, not just the ID — saves the client a follow-up GET
Locationheader points to the new resource- Server fills in
id,status,created_at,updated_at— never trust these from the client
Response — 422 Validation Failed
Section titled “Response — 422 Validation Failed”HTTP/1.1 422 Unprocessable EntityContent-Type: application/json
{ "error": { "code": "VALIDATION_FAILED", "message": "Request body has invalid fields", "details": { "fields": { "email": "must be a valid email address", "role": "must be one of: engineer, manager, admin" } }, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" }}Response — 409 Conflict (duplicate)
Section titled “Response — 409 Conflict (duplicate)”{ "error": { "code": "USER_ALREADY_EXISTS", "message": "A user with this email already exists", "details": { "existing_id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b" }, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" }}GET — Single resource
Section titled “GET — Single resource”Request
Section titled “Request”GET /v1/users/01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b HTTP/1.1Authorization: Bearer eyJhbGc...If-None-Match: "a1b2c3d4"If-None-Match enables conditional GET — server returns 304 if the resource hasn’t changed.
Response — 200 OK
Section titled “Response — 200 OK”HTTP/1.1 200 OKContent-Type: application/jsonETag: "a1b2c3d4"Cache-Control: private, max-age=60
{ "data": { "id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b", "name": "Atif", "role": "engineer", "status": "active", "metadata": { "team": "sre", "location": "livermore" }, "created_at": "2026-05-06T14:32:10Z", "updated_at": "2026-05-06T14:32:10Z" }}Response — 304 Not Modified
Section titled “Response — 304 Not Modified”HTTP/1.1 304 Not ModifiedETag: "a1b2c3d4"No body. Saves bandwidth on cached resources.
Response — 404 Not Found
Section titled “Response — 404 Not Found”{ "error": { "code": "NOT_FOUND", "message": "User not found", "details": { "id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b" }, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" }}Field projection
Section titled “Field projection”GET /v1/users/01933f8a-...?fields=id,email,name HTTP/1.1Returns only requested fields. Useful for mobile clients and reducing payload size.
GET — Collection (list with pagination)
Section titled “GET — Collection (list with pagination)”Request — cursor-based (preferred)
Section titled “Request — cursor-based (preferred)”GET /v1/users?status=active&role=engineer&limit=50&sort=-created_at HTTP/1.1Authorization: Bearer eyJhbGc...Query parameter conventions:
limit— max items (server caps at e.g. 100)cursor— opaque token; clients must not parse itsort—fieldfor asc,-fieldfor desc; multiple viasort=-created_at,name- Filters as flat params:
?status=active&role=engineer - Range filters:
?created_after=2026-01-01&created_before=2026-05-01 - Multi-value:
?status=active,pendingor repeated?status=active&status=pending
Response — 200 OK
Section titled “Response — 200 OK”{ "data": [ { "id": "01933f8a-...", "name": "Atif", "role": "engineer", "status": "active", "created_at": "2026-05-06T14:32:10Z", "updated_at": "2026-05-06T14:32:10Z" }, { "id": "01933f7b-...", "name": "Sara", "role": "engineer", "status": "active", "created_at": "2026-05-05T09:11:42Z", "updated_at": "2026-05-05T09:11:42Z" } ], "pagination": { "next_cursor": "eyJpZCI6IjAxOTMzZjdiIn0=", "has_more": true, "limit": 50 }}Subsequent page
Section titled “Subsequent page”GET /v1/users?cursor=eyJpZCI6IjAxOTMzZjdiIn0=&limit=50 HTTP/1.1Last page
Section titled “Last page”{ "data": [ ... ], "pagination": { "next_cursor": null, "has_more": false, "limit": 50 }}Empty result — 200 (not 404)
Section titled “Empty result — 200 (not 404)”{ "data": [], "pagination": { "next_cursor": null, "has_more": false, "limit": 50 }}Cursor vs offset: cursor pagination is stable under inserts/deletes (no skipped or duplicated rows when data shifts), and it scales — unlike
OFFSET 100000which forces the DB to scan and discard. Use offset only for small, bounded admin lists.
PUT — Full replacement
Section titled “PUT — Full replacement”PUT replaces the entire resource. Idempotent — same request, same outcome.
Request
Section titled “Request”PUT /v1/users/01933f8a-... HTTP/1.1Content-Type: application/jsonAuthorization: Bearer eyJhbGc...If-Match: "a1b2c3d4"
{ "name": "Atif Khan", "role": "senior_engineer", "metadata": { "team": "sre", "location": "livermore" }}If-Match enables optimistic concurrency. If someone else updated the resource since you read it (ETag changed), the server returns 412.
Response — 200 OK
Section titled “Response — 200 OK”{ "data": { "id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b", "name": "Atif Khan", "role": "senior_engineer", "status": "active", "metadata": { "team": "sre", "location": "livermore" }, "created_at": "2026-05-06T14:32:10Z", "updated_at": "2026-05-06T15:01:22Z" }}ETag header changes: ETag: "b2c3d4e5"
Response — 412 Precondition Failed
Section titled “Response — 412 Precondition Failed”{ "error": { "code": "PRECONDITION_FAILED", "message": "Resource was modified by another request. Re-read and retry.", "trace_id": "..." }}PATCH — Partial update
Section titled “PATCH — Partial update”Two flavors — pick one and document it. Most teams use JSON Merge Patch (RFC 7396) for simplicity.
Request — JSON Merge Patch
Section titled “Request — JSON Merge Patch”PATCH /v1/users/01933f8a-... HTTP/1.1Content-Type: application/merge-patch+jsonAuthorization: Bearer eyJhbGc...If-Match: "a1b2c3d4"
{ "role": "staff_engineer", "metadata": { "team": "platform" }}Merge Patch semantics:
- Fields present → set to that value
- Fields absent → unchanged
- Fields with
null→ cleared (set to null/deleted) - Nested objects merge recursively (not replaced)
To delete a field, send null:
{ "metadata": { "location": null } }Request — JSON Patch (RFC 6902, more powerful but verbose)
Section titled “Request — JSON Patch (RFC 6902, more powerful but verbose)”PATCH /v1/users/01933f8a-... HTTP/1.1Content-Type: application/json-patch+json
[ { "op": "replace", "path": "/role", "value": "staff_engineer" }, { "op": "add", "path": "/metadata/team", "value": "platform" }, { "op": "remove", "path": "/metadata/location" }]Response — 200 OK
Section titled “Response — 200 OK”Returns the updated resource (same shape as PUT response). Some teams return 204 No Content with no body — simpler but forces a follow-up GET. Returning the body is friendlier.
DELETE
Section titled “DELETE”Idempotent — deleting an already-deleted resource returns success.
Request
Section titled “Request”DELETE /v1/users/01933f8a-... HTTP/1.1Authorization: Bearer eyJhbGc...If-Match: "a1b2c3d4"Response — 204 No Content (preferred)
Section titled “Response — 204 No Content (preferred)”HTTP/1.1 204 No ContentNo body.
Response — 200 OK with body (alternative)
Section titled “Response — 200 OK with body (alternative)”Useful when the delete returns useful info (e.g., soft-delete returns the tombstoned resource):
{ "data": { "id": "01933f8a-...", "status": "deleted", "deleted_at": "2026-05-06T15:30:00Z" }}Soft delete vs hard delete
Section titled “Soft delete vs hard delete”- Soft delete: set
deleted_attimestamp, exclude from default queries, allow?include_deleted=truefor admins - Hard delete: row gone forever — usually only for compliance (GDPR right-to-be-forgotten)
- Document which one you do; defaults vary by team
Bulk operations
Section titled “Bulk operations”Bulk create — POST to collection with array
Section titled “Bulk create — POST to collection with array”POST /v1/users/bulk HTTP/1.1Content-Type: application/jsonIdempotency-Key: 7c9e...
{ "items": [ { "email": "invalid-email", "name": "Charlie" } ]}Response — 207 Multi-Status (partial success)
Section titled “Response — 207 Multi-Status (partial success)”{ "data": { "succeeded": [ ], "failed": [ { "index": 2, "error": { "code": "VALIDATION_FAILED", "message": "Invalid email", "details": { "fields": { "email": "must be a valid email" } } } } ] }, "summary": { "total": 3, "succeeded": 2, "failed": 1 }}Alternatively: all-or-nothing semantics, return 422 if any item fails. Document which behavior the endpoint has.
Async / long-running operations
Section titled “Async / long-running operations”For operations that take more than ~1–2 seconds, return 202 and a status URL.
Request
Section titled “Request”POST /v1/exports HTTP/1.1Content-Type: application/json
{ "type": "user_data", "filters": { "created_after": "2026-01-01" }, "format": "csv"}Response — 202 Accepted
Section titled “Response — 202 Accepted”HTTP/1.1 202 AcceptedLocation: /v1/operations/op_01933f8c-...Retry-After: 5
{ "data": { "operation_id": "op_01933f8c-...", "status": "pending", "created_at": "2026-05-06T15:45:00Z", "status_url": "/v1/operations/op_01933f8c-..." }}Polling status — GET /v1/operations/{id}
Section titled “Polling status — GET /v1/operations/{id}”{ "data": { "operation_id": "op_01933f8c-...", "status": "in_progress", "progress": { "completed": 4200, "total": 10000 }, "created_at": "2026-05-06T15:45:00Z", "updated_at": "2026-05-06T15:45:30Z" }}Completed
Section titled “Completed”{ "data": { "operation_id": "op_01933f8c-...", "status": "succeeded", "result": { "download_url": "https://signed-url.example.com/exports/...", "expires_at": "2026-05-06T16:45:00Z", "row_count": 10000 }, "created_at": "2026-05-06T15:45:00Z", "completed_at": "2026-05-06T15:46:12Z" }}Failed
Section titled “Failed”{ "data": { "operation_id": "op_01933f8c-...", "status": "failed", "error": { "code": "EXPORT_FAILED", "message": "Source database unavailable" }, "created_at": "2026-05-06T15:45:00Z", "completed_at": "2026-05-06T15:46:30Z" }}For pushed updates instead of polling, expose webhooks: client registers a callback URL, server POSTs the same payload when status changes.
Internal microservice variations
Section titled “Internal microservice variations”Internal APIs can be looser, but document the differences explicitly.
Differences from public endpoints
Section titled “Differences from public endpoints”Auth header (mTLS-fronted):
POST /internal/users HTTP/1.1Content-Type: application/jsonX-Service-Identity: order-service.prodX-Request-ID: 9b2f...X-Trace-ID: 4bf92f3577b34da6a3ce929d0e0e4736Response — exposes internal fields:
{ "data": { "id": "01933f8a-...", "name": "Atif", "role": "engineer", "status": "active", "_internal": { "shard_id": 7, "tenant_id": "t_acme", "feature_flags": ["beta_dashboard"], "version": 42 }, "created_at": "2026-05-06T14:32:10Z", "updated_at": "2026-05-06T14:32:10Z" }}Why the _internal namespace: keeps internal-only fields visually separated. If this DTO ever leaks to a public response by mistake, the convention makes it obvious in code review.
Health endpoints
Section titled “Health endpoints”Liveness — am I running?
GET /healthz HTTP/1.1{ "status": "ok" }Cheap, no dependency checks. Used by k8s livenessProbe to decide whether to restart the pod.
Readiness — am I ready to serve traffic?
GET /readyz HTTP/1.1{ "status": "ok", "checks": { "postgres": "ok", "redis": "ok", "downstream_user_service": "ok" }}Failure:
{ "status": "degraded", "checks": { "postgres": "ok", "redis": "timeout: 5s", "downstream_user_service": "ok" }}Returns 503. Used by readinessProbe to remove the pod from the service’s endpoint list without killing it.
Error code conventions
Section titled “Error code conventions”A short, stable code is more useful than a long message. Codes are part of the API contract — don’t change them.
| HTTP status | Common codes |
|---|---|
| 400 | BAD_REQUEST, MALFORMED_JSON |
| 401 | UNAUTHORIZED, TOKEN_EXPIRED, INVALID_TOKEN |
| 403 | FORBIDDEN, INSUFFICIENT_SCOPE |
| 404 | NOT_FOUND, USER_NOT_FOUND (resource-specific is fine) |
| 409 | CONFLICT, ALREADY_EXISTS, VERSION_CONFLICT |
| 412 | PRECONDITION_FAILED |
| 422 | VALIDATION_FAILED |
| 429 | RATE_LIMITED |
| 500 | INTERNAL_ERROR |
| 502 | UPSTREAM_ERROR |
| 503 | SERVICE_UNAVAILABLE, DEPENDENCY_DOWN |
| 504 | UPSTREAM_TIMEOUT |
429 with Retry-After
Section titled “429 with Retry-After”HTTP/1.1 429 Too Many RequestsRetry-After: 60X-RateLimit-Limit: 100X-RateLimit-Remaining: 0X-RateLimit-Reset: 1714999200
{ "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Retry after 60 seconds.", "details": { "limit": 100, "window_seconds": 60 }, "trace_id": "..." }}Versioning in payloads
Section titled “Versioning in payloads”When a field’s meaning changes, prefer adding a new field over changing the old one — backward compatibility matters.
{ "data": { "id": "01933f8a-...", "name": "Atif", "role": "engineer", // legacy: simple string "role_v2": { // new: structured "name": "engineer", "level": "senior", "track": "sre" } }}Mark old fields as deprecated in OpenAPI; remove only on a major version bump.
Request/response checklist
Section titled “Request/response checklist”Before shipping an endpoint, verify:
- Status codes match semantics (201 for create, 204 for delete, 422 for validation, etc.)
- All error responses use the same envelope shape
- Timestamps are RFC 3339 with timezone
- IDs are strings, even if backed by integers
- Collections are paginated; no unbounded lists
- POSTs that create or charge accept
Idempotency-Key X-Request-IDpropagated through to logs- ETags on resources that benefit from caching or optimistic concurrency
- Deprecated fields documented; nothing silently removed
- OpenAPI spec matches the actual implementation (contract test)