Skip to content

Payloads and Responses

First PublishedByAtif Alam

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.

  • Field names: snake_case in 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 null over field omission; document which fields can be null
  • Unknown fields: clients ignore; servers reject (strict by default for security)
{
"data": {
"id": "01933f8a-...",
"type": "user",
"..."
}
}
{
"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 data wrapper) for single-resource endpoints. Pick one and be consistent. The wrapper makes it easier to add metadata (meta, links) later without breaking changes.


POST /v1/users HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGc...
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Request-ID: 9b2f...
{
"email": "[email protected]",
"name": "Atif",
"role": "engineer",
"metadata": {
"team": "sre",
"location": "livermore"
}
}

Why these headers:

  • Idempotency-Key — client-generated UUID. Server stores (key, principal) → response for 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.
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/users/01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b
X-Request-ID: 9b2f...
{
"data": {
"id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b",
"email": "[email protected]",
"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
  • Location header points to the new resource
  • Server fills in id, status, created_at, updated_at — never trust these from the client
HTTP/1.1 422 Unprocessable Entity
Content-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"
}
}
{
"error": {
"code": "USER_ALREADY_EXISTS",
"message": "A user with this email already exists",
"details": {
"email": "[email protected]",
"existing_id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b"
},
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}

GET /v1/users/01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b HTTP/1.1
Authorization: Bearer eyJhbGc...
If-None-Match: "a1b2c3d4"

If-None-Match enables conditional GET — server returns 304 if the resource hasn’t changed.

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "a1b2c3d4"
Cache-Control: private, max-age=60
{
"data": {
"id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b",
"email": "[email protected]",
"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"
}
}
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"

No body. Saves bandwidth on cached resources.

{
"error": {
"code": "NOT_FOUND",
"message": "User not found",
"details": { "id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b" },
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}
GET /v1/users/01933f8a-...?fields=id,email,name HTTP/1.1

Returns only requested fields. Useful for mobile clients and reducing payload size.


GET /v1/users?status=active&role=engineer&limit=50&sort=-created_at HTTP/1.1
Authorization: Bearer eyJhbGc...

Query parameter conventions:

  • limit — max items (server caps at e.g. 100)
  • cursor — opaque token; clients must not parse it
  • sortfield for asc, -field for desc; multiple via sort=-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,pending or repeated ?status=active&status=pending
{
"data": [
{
"id": "01933f8a-...",
"email": "[email protected]",
"name": "Atif",
"role": "engineer",
"status": "active",
"created_at": "2026-05-06T14:32:10Z",
"updated_at": "2026-05-06T14:32:10Z"
},
{
"id": "01933f7b-...",
"email": "[email protected]",
"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
}
}
GET /v1/users?cursor=eyJpZCI6IjAxOTMzZjdiIn0=&limit=50 HTTP/1.1
{
"data": [ ... ],
"pagination": {
"next_cursor": null,
"has_more": false,
"limit": 50
}
}
{
"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 100000 which forces the DB to scan and discard. Use offset only for small, bounded admin lists.


PUT replaces the entire resource. Idempotent — same request, same outcome.

PUT /v1/users/01933f8a-... HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGc...
If-Match: "a1b2c3d4"
{
"email": "[email protected]",
"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.

{
"data": {
"id": "01933f8a-7d4e-7c9a-b4e1-1c2d3e4f5a6b",
"email": "[email protected]",
"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"

{
"error": {
"code": "PRECONDITION_FAILED",
"message": "Resource was modified by another request. Re-read and retry.",
"trace_id": "..."
}
}

Two flavors — pick one and document it. Most teams use JSON Merge Patch (RFC 7396) for simplicity.

PATCH /v1/users/01933f8a-... HTTP/1.1
Content-Type: application/merge-patch+json
Authorization: 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.1
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/role", "value": "staff_engineer" },
{ "op": "add", "path": "/metadata/team", "value": "platform" },
{ "op": "remove", "path": "/metadata/location" }
]

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.


Idempotent — deleting an already-deleted resource returns success.

DELETE /v1/users/01933f8a-... HTTP/1.1
Authorization: Bearer eyJhbGc...
If-Match: "a1b2c3d4"
HTTP/1.1 204 No Content

No 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: set deleted_at timestamp, exclude from default queries, allow ?include_deleted=true for 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 create — POST to collection with array

Section titled “Bulk create — POST to collection with array”
POST /v1/users/bulk HTTP/1.1
Content-Type: application/json
Idempotency-Key: 7c9e...
{
"items": [
{ "email": "[email protected]", "name": "Alice" },
{ "email": "[email protected]", "name": "Bob" },
{ "email": "invalid-email", "name": "Charlie" }
]
}

Response — 207 Multi-Status (partial success)

Section titled “Response — 207 Multi-Status (partial success)”
{
"data": {
"succeeded": [
{ "index": 0, "id": "01933f8a-...", "email": "[email protected]" },
{ "index": 1, "id": "01933f8b-...", "email": "[email protected]" }
],
"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.


For operations that take more than ~1–2 seconds, return 202 and a status URL.

POST /v1/exports HTTP/1.1
Content-Type: application/json
{
"type": "user_data",
"filters": { "created_after": "2026-01-01" },
"format": "csv"
}
HTTP/1.1 202 Accepted
Location: /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"
}
}
{
"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"
}
}
{
"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 APIs can be looser, but document the differences explicitly.

Auth header (mTLS-fronted):

POST /internal/users HTTP/1.1
Content-Type: application/json
X-Service-Identity: order-service.prod
X-Request-ID: 9b2f...
X-Trace-ID: 4bf92f3577b34da6a3ce929d0e0e4736

Response — exposes internal fields:

{
"data": {
"id": "01933f8a-...",
"email": "[email protected]",
"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.

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.


A short, stable code is more useful than a long message. Codes are part of the API contract — don’t change them.

HTTP statusCommon codes
400BAD_REQUEST, MALFORMED_JSON
401UNAUTHORIZED, TOKEN_EXPIRED, INVALID_TOKEN
403FORBIDDEN, INSUFFICIENT_SCOPE
404NOT_FOUND, USER_NOT_FOUND (resource-specific is fine)
409CONFLICT, ALREADY_EXISTS, VERSION_CONFLICT
412PRECONDITION_FAILED
422VALIDATION_FAILED
429RATE_LIMITED
500INTERNAL_ERROR
502UPSTREAM_ERROR
503SERVICE_UNAVAILABLE, DEPENDENCY_DOWN
504UPSTREAM_TIMEOUT
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714999200
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Retry after 60 seconds.",
"details": { "limit": 100, "window_seconds": 60 },
"trace_id": "..."
}
}

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.


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-ID propagated 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)