Skip to main content

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 } }
]
}
FieldTypeRequiredMeaning
successbooleanyesAlways false on an error body — the discriminator that mirrors the { success: true, data } success envelope. The client branches on it before reading errors.
traceIdstringyesCorrelates 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.
errorsarrayyesOne or more errors; never empty.
errors[].codestringyesStable machine code — the backend error-enum member name. The frontend keys i18n and handling off this, never off message.
errors[].messagestringyesHuman-readable English fallback (shown only when the client has no localized text for code).
errors[].fieldstringnoThe request field this error is about (validation). Omitted for request-wide errors.
errors[].metaobjectnoValues 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).

BackendMyLegalTeam.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.

Frontendsrc/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 } }
]
}