API error contract
The error body every new endpoint returns, and how the frontend reads it. Designed clean — it is
not bound to the legacy DomainNotification shape. Existing endpoints predate this and keep their
current body; they are not migrated (see Response envelope & versioning).
It is the error half of the envelope: success responses are the mirror { "success": true, "data": … },
and success is the always-present discriminator (false here) the client branches on first.
Shape
{
"success": false,
"traceId": "0HN7…:00000003",
"errors": [
{ "code": "TooManyInvites", "message": "At most 100 invites per request.", "field": "invites", "meta": { "max": 100 } }
]
}
| Field | Type | Required | Meaning |
|---|---|---|---|
success | boolean | yes | Always false on an error body — the discriminator that mirrors the { success: true, data } success envelope. The client branches on it before reading errors. |
traceId | string | yes | Correlates this response to the server logs for the same request. Shown to the user for support ("quote this reference") and used when reporting a bug. |
errors | array | yes | One or more errors; never empty. |
errors[].code | string | yes | Stable machine code — the backend error-enum member name. The frontend keys i18n and handling off this, never off message. |
errors[].message | string | yes | Human-readable English fallback (shown only when the client has no localized text for code). |
errors[].field | string | no | The request field this error is about (validation). Omitted for request-wide errors. |
errors[].meta | object | no | Values for localizing message by code: the client renders its own text from a template keyed on code, filling the placeholders from meta (e.g. { "max": 100 } → "Máximo 100 convites"). Omitted when the message has no dynamic parts. |
The HTTP status code carries the category — the body never repeats it:
400 bad request / validation · 401 unauthenticated · 403 forbidden · 404 not found ·
409 conflict · 500 server error.
Types
Keep both in sync (or generate the frontend type from the backend's OpenAPI schema).
Backend — MyLegalTeam.Application:
public record ApiError(string Code, string Message, string? Field = null, IReadOnlyDictionary<string, object>? Meta = null);
public record ApiErrorResponse(bool Success, string TraceId, IReadOnlyList<ApiError> Errors); // Success is always false
TraceId is Activity.Current?.Id ?? HttpContext.TraceIdentifier, set once where the envelope is
built — handlers never populate it.
Frontend — src/lib/api-client.ts:
interface ApiError { code: string; message: string; field?: string; meta?: Record<string, unknown> }
interface ApiErrorResponse { success: false; traceId: string; errors: ApiError[] }
Rate limiting (429)
429 Too Many Requests uses the same envelope — the retry delay rides in meta, alongside the
standard Retry-After header:
{
"success": false,
"traceId": "0HN7…:00000005",
"errors": [
{ "code": "TooManyRequests", "message": "Too many requests — try again later.", "meta": { "retryAfterSeconds": 900 } }
]
}