Responses
Application JSON endpoints return response.JSON[T] from
internal/platform/api/response. The payload stays typed under data, with
optional operation-specific meta. Request IDs and API versions are response
headers only. Huma owns
serialization and OpenAPI; handlers still register directly with huma.Register.
A normal handler constructs a response; it does not write JSON or headers itself. Huma writes the returned output after the handler finishes. Custom files and streams can retain their own body format.
Return a result#
// In a handler returning (*response.JSON[UserResponse], error):
return response.OK(result), nil
// Optional metadata is a JSON object. Use serializable values.
return response.OK(result).WithMeta(map[string]any{"source": "cache"}), nil
{
"meta": {"source": "cache"},
"data": {"id": "<resource-id>", "name": "Ada Example"}
}
| Helper | Handler output | HTTP status |
|---|---|---|
response.OK(data) |
*response.JSON[T] |
200 |
response.Created(data) |
*response.JSON[T] |
201 |
response.Accepted(data) |
*response.JSON[T] |
202 |
response.NewJSON(data, status) |
*response.JSON[T] |
Explicit success status permitting a body |
response.NoContent() |
*response.Empty |
204, with no body |
Use the constructors: Huma reads JSON.Status directly, so a literal with an
unset status is invalid. NewJSON panics on a non-success status or 204/205;
these are programmer errors. Return failures with response.Error instead.
For NoContent, omit Operation.DefaultStatus or set it to 204.
Set Operation.DefaultStatus: 201 or 202 for those fixed outcomes. A constructor
runs at request time, so it cannot update the OpenAPI operation defined at startup.
For dynamic success statuses, document each status explicitly; Identity sign-in
returns 200 for existing accounts and 201 for new ones. Its route group shares
the generated success schema between both statuses.
Always select public response fields instead of exposing persistence entities.
data is always present, including null for a nil payload. meta is omitted
when empty. WithMeta copies the outer map; nested values remain caller-owned.
Do not modify a response while another goroutine is sending it.
Pagination stays under data with its typed items, page, per_page, total
and total_pages. Extra metadata does not replace that established page contract.
Identity sign-in uses the same token-and-user response for both 201 (new account)
and 200 (existing account). The frontend checks data.user.profile_complete after
either status to decide whether to request profile details.
Declare multiple success statuses#
Some operations have more than one successful outcome. Identity's OTP and social verification return 201 for a new account and 200 for an existing one, using the same body schema. Its registration group declares the additional status:
signIns := huma.NewGroup(routes)
signIns.UseSimpleModifier(func(op *huma.Operation) {
created := *op.Responses["200"]
created.Description = "New account created"
op.Responses["201"] = &created
})
// Register only operations with that same 200/201 body contract on signIns.
This fragment imports Huma and assumes routes is the module's API. The handler
chooses response.Created(result) or response.OK(result) at runtime. The modifier
copies the generated 200 response after Huma has built its typed schema; do not
apply it to bodyless or differently shaped operations.
Return a page#
Return a typed page under data, preserving pagination.Pagination as the paging
input. This handler fragment assumes page is *pagination.Page[*entities.User]
and uses Identity's existing public response conversion:
items := make([]UserResponse, len(page.Items))
for i, user := range page.Items {
items[i] = newUserResponse(user)
}
paging := pagination.NewPagination(page.Page, page.PerPage)
return response.OK(pagination.NewPage(items, paging, page.Total)), nil
Import github.com/wisalgroup/labena/pkg/pagination. An empty result has this shape:
{
"data": {
"items": [], "page": 1, "per_page": 20, "total": 0, "total_pages": 0
}
}
pagination.NewPage makes items a non-nil slice and calculates total_pages.
It does not query the database. See page requests and
page queries for the other two steps.
Response headers#
The API server generates a fresh request ID and returns it in X-Request-ID.
Incoming request IDs are not trusted. The same ID is attached to request logs;
use response.RequestID(ctx) when an HTTP handler needs the correlation ID.
It is not inserted into JSON bodies.
X-API-Version defaults to v1. Set api.Options.APIVersion
when constructing a server for another API version. This setting does not change
route prefixes; keep the Huma group's path consistent with it. Options.Version
is the application release shown in OpenAPI and does not change X-API-Version.
The API version is scoped to the server, not inferred from arbitrary request paths.
Successes, errors, empty responses and streams receive both headers. This requires
serving the platform server itself, not bypassing it through server.Mux.
The default CORS configuration exposes both headers to browser clients.
Preserve that exposure when customizing CORS. Clients should capture X-Request-ID
alongside an error; a copied JSON body alone has no correlation ID.
Return an error#
if err != nil {
return nil, response.Error(ctx, err)
}
Services use transport-independent apperror errors; see error ownership and wrapping. At the HTTP boundary,
response.Error(ctx, err) recognizes wrapped errors, preserves their causes and hides
private operational diagnostics. Huma handles the returned status error.
Use response.WriteError to reject an application failure inside Huma middleware
and return without calling next. Native Huma status errors and their headers
remain supported when returned from typed handlers.
Standard net/http handlers can use response.WriteHTTPError(ctx, w, err) before
committing their response. These writers share the same service-error mapping,
safe logging. The API server supplies the response headers and installs Huma's
error-sanitization hook automatically; ordinary handlers need no extra setup.
Both application and Huma validation errors use application/problem+json with
flat RFC 9457 fields and optional operation-specific meta. There is no outer
error or data field on a problem. OpenAPI describes this body without
request_id or api_version, and Huma's global error hooks remain unchanged.
Multiple validation messages#
Collect application validation messages in the existing apperror.InvalidFields:
// In a service; no Huma or response imports are needed.
return apperror.InvalidFields(map[string][]string{
"email": {"Use your work email.", "This address is not eligible."},
"profile.name": {"Choose a display name."},
})
The handler returns nil, response.Error(ctx, err). Its response is:
{
"type": "urn:go-modular:error:validation_failed",
"title": "Unprocessable Entity",
"status": 422,
"detail": "The given data was invalid.",
"errors": [
{"message": "Use your work email.", "location": "body.email"},
{"message": "This address is not eligible.", "location": "body.email"},
{"message": "Choose a display name.", "location": "body.profile.name"}
]
}
Every message becomes a separate Huma error detail. Field keys are sorted for
stable ordering; message order within each field is preserved. Bare names refer
to the body, including nested paths such as items[0].name. Explicit HTTP paths
such as query.page, path.id, header.X-Client or body.email are preserved.
Keep transport-specific paths at the HTTP boundary when possible. An empty key
refers to the whole body. Use one InvalidFields to aggregate field messages;
errors.Join is not a validation collection API.
Clients branch on type and HTTP status, not human-readable messages. Both native
and application validation expose an errors array with message and location.
Submitted values and parser internals are omitted. Huma schema diagnostics are
redacted; application messages are deliberately public and preserved. Never put
provider text, credentials or submitted secrets in those messages. Huma may report
several native validation errors before the handler runs; business validation runs
after that stage succeeds.
| Error category | HTTP status |
|---|---|
| Invalid JSON/input | 400 |
| Unauthenticated / forbidden | 401 / 403 |
| Not found / conflict | 404 / 409 |
| Body too large / unsupported media | 413 / 415 |
| Schema or application validation | 422 |
| Rate limited | 429 |
| Internal or unknown failure | 500 |
| Unavailable | 503 |
Huma documents a default problem response. Use Operation.Errors for explicit
failure statuses when clients need them in the schema.
Custom outputs and streams#
Use native Huma outputs for custom headers. Declare special fields explicitly;
do not embed response.JSON[T] into an output struct.
type CreatedUserOutput struct {
Location string `header:"Location"`
Body response.Envelope[UserResponse]
}
// Register with Operation.DefaultStatus: 201.
return &CreatedUserOutput{
Location: "/api/v1/users/" + result.ID.String(),
Body: response.Envelope[UserResponse]{Data: result},
}, nil
The envelope receives automatic metadata. Native bare bodies are also supported
when an endpoint deliberately needs a different wire contract; they are not
silently wrapped. File streams use Body func(huma.Context) and keep their bytes.
Health probes, dashboards, metrics and standard router 404/405 responses retain
their native formats, with metadata headers. The request logger covers them too.
Once a response is committed, a later failure must abort the transport rather than append a problem body. See handlers for streaming cleanup. Cancellation does not undo a committed transaction or accepted task; retries must account for duplicate or uncertain outcomes.
Verify the response contract#
For an endpoint, check the runtime status and body and its /openapi.json
entry. Common mistakes have straightforward fixes:
| Symptom | Fix |
|---|---|
| Runtime 201 but docs show 200 | Set DefaultStatus: 201 on the operation |
| Unset or invalid status | Construct response.JSON[T] with its helper instead of a zero-valued literal |
| Missing response headers in an integration test | Serve the platform server, not only server.Mux |
items: null |
Construct the page with pagination.NewPage |
| Private entity fields appear | Map to a transport-owned response type |
| Client cannot find validation messages | Read the top-level problem errors array; errors have no data envelope |
Success clients read data; problem clients read top-level fields. Use
the X-Request-ID response header to investigate failures through
observability. Continue with error handling and
live API documentation.