Skip to main content

Localize API Error Messages Without Coupling Apps to Server Copy

2026-08-27

Localize API Error Messages Without Coupling Apps to Server Copy

API error localization can fail even when every visible app screen has been translated. A payment fails, an invitation expires, or a validation request is rejected. The API returns English prose, and the app shows it unchanged. Parsing that sentence is no better because any server edit can break client behavior.

Give each failure a stable machine-readable identity and structured parameters. The layer that owns the user interface can then choose reviewed copy for the current locale. App behavior no longer depends on server wording, but the server can still localize messages that the client cannot know ahead of time.

Why server prose breaks localization

Consider an API response containing "message": "Your card was declined". One field now identifies the failure, helps developers diagnose it, and supplies the sentence shown to the user. Those responsibilities do not share a release cycle.

The service team may change the wording to improve diagnostics. The app might need a shorter version in a dialog. Support or legal reviewers may approve different copy for particular markets. Branching on the English sentence turns punctuation into protocol. Displaying it directly sends text around the app's translation review.

Developers have been arguing over this boundary for years. One Software Engineering Stack Exchange question asks whether a JSON API should return readable text or an i18n key for the client to translate. Another asks which layer should produce human-readable error messages. The answers offer different ownership models because the right owner depends on the message. The API contract therefore has to keep machine behavior separate from display policy.

Give errors a stable identity

An expected application failure needs three distinct pieces of information:

  1. A stable type or reason for client logic.
  2. Structured parameters that describe this occurrence.
  3. A developer-facing diagnostic for logs and debugging.

RFC 9457 defines Problem Details for HTTP APIs, including a machine-readable type that identifies the problem class. Standard members such as status, title, and detail describe the response, and extensions can carry application data. An app can adopt the standard directly or preserve the same separation in a different transport.

Here is an expired-invitation response:

{
  "type": "urn:example:problem:invite-expired",
  "status": 410,
  "reason": "INVITE_EXPIRED",
  "message": "The invitation expired before it was accepted.",
  "params": {
    "expiredAt": "2026-07-28T14:00:00Z"
  },
  "traceId": "req_01JXYZ"
}

Client logic matches reason or type, never message. The presentation layer formats expiredAt with the user's locale and time zone, then renders a resource such as errors.inviteExpired.

Google makes the same distinction in AIP-193. Its main message is an English, developer-facing debug message. Programmatic handling uses machine-readable error information, while a LocalizedMessage detail can carry localized text. Diagnostics and localized presentation remain separate fields instead of competing for one string.

Choose who owns the copy

Expected failures understood by the installed app should usually use app-owned copy. Invalid credentials, expired sessions, duplicate names, disabled features, and expired invitations fit this category. The app can review each message with its other interface text and adapt it for an inline field, dialog, banner, or full screen.

The server should own localization when the content changes independently of an app release. A new compliance rule, a marketplace moderation category, or tenant-authored instructions may be impossible for an older client to ship in advance. Return a stable reason in these cases too, along with localized display text and a declared locale. The client still needs a fallback when that locale is unavailable.

Assign ownership with these rules:

  • Localize in app resources when the app knows the failure and controls the surface.
  • Use server-localized text for dynamic policy or content that cannot wait for an app release.
  • Pick an authoritative source and a documented fallback order when either side can render the message.
  • Keep developer-only diagnostics out of the user interface.
  • Send unrecognized reasons to a generic local message while retaining diagnostics for telemetry.

An opaque translation key by itself is not enough for an external consumer that lacks the catalog. A polished sentence by itself is not enough for reliable behavior, analytics, or recovery. Stable identity and presentation text solve different problems.

Build a reviewed error catalog

Create one catalog entry for each expected reason owned by the app. Android's string resource documentation covers locale-specific text and formatted values. An iOS string catalog or another mobile message system can follow the same ownership model.

Each record needs enough context for engineers and translators:

Field Example
API reason INVITE_EXPIRED
App message ID errors.inviteExpired
Parameters expiredAt
Surface Full-screen invitation result
Recovery action Request a new invitation
Translator note Invitation grants workspace access
Fallback ID errors.generic
Owner Identity team

Define the type and formatting rule for every parameter. Format dates with the user's locale and time zone. Currency requires both amount and currency code. Counts need the locale's plural rules. User-generated names may require bidi isolation and length limits.

Messages should be complete rather than assembled from fragments. "Invitation expired {date}" lets a translator place the date where the language requires it. A translated prefix concatenated with a date brings back the grammar problem that message formatting exists to solve.

Map transport failures in one place

Keep the transport-to-presentation mapping in a boundary module. Network code decodes the response. Feature code receives a typed failure. UI code gets a message ID, arguments, and recovery action.

function mapApiProblem(problem): AppFailure
  switch problem.reason
    case "INVITE_EXPIRED":
      requireTimestamp(problem.params.expiredAt)
      return AppFailure(
        messageId = "errors.inviteExpired",
        args = { "expiredAt": problem.params.expiredAt },
        action = "requestNewInvite"
      )

    case "RATE_LIMITED":
      retryAt = optionalTimestamp(problem.params.retryAt)
      return AppFailure(
        messageId = retryAt ? "errors.rateLimitedUntil" : "errors.rateLimited",
        args = retryAt ? { "retryAt": retryAt } : {},
        action = "retry"
      )

    default:
      return AppFailure(
        messageId = "errors.generic",
        args = {},
        action = "retryOrContactSupport",
        diagnostic = problem.message,
        traceId = problem.traceId
      )

This module rejects malformed parameters instead of interpolating null into a sentence. Centralizing it also stops two feature screens from choosing different fallbacks for the same reason.

Recovery belongs in the mapping. An authentication failure can open sign-in. A validation failure can focus the affected field. A rate limit can offer a retry at the appropriate time. Good translation without a usable recovery path leaves the user stuck in the same failure.

Cover transport and unknown failures

Some failures never arrive as a valid application problem. The device might be offline, TLS negotiation might fail, a gateway might return HTML, or an older service might omit the reason. Give these conditions local message IDs rather than folding all of them into an unknown server error.

Use this precedence order:

  1. Map a recognized reason to app-owned copy.
  2. Use verified server-localized text when the contract permits it and its locale matches.
  3. Map a known HTTP or transport condition to a local generic message.
  4. Fall back to the app's localized unknown-error message.

Do not show stack traces, database errors, upstream provider text, or arbitrary detail values. Send approved diagnostic fields through the telemetry path, redact sensitive data, and retain a trace ID for support. User copy should say what happened and what the user can do without leaking implementation details.

Retry wording also needs care. A timeout does not prove that the server abandoned an operation. For a non-idempotent request, check its status before telling the user to retry. Do not claim that data was saved until the app has durable confirmation.

Evolve reasons without breaking old apps

Reasons can change as long as their identities remain predictable. Add new reasons safely by giving unknown values a reliable fallback. Retire an old reason only after supported app versions no longer depend on it. Developer-facing prose can change without affecting the client's branch logic.

Compatibility rules should be explicit:

  • Never assign an existing reason to a different failure.
  • Keep required parameters stable for the reason's lifetime.
  • Add only optional parameters that old clients can ignore.
  • Create a new reason when the required recovery changes.
  • Record the minimum app version that understands each reason.
  • Keep a generic fallback translated in every supported locale.

A server can emit a new reason before all users update. Test the oldest supported client against it. That version should show its generic local message and record the unknown reason. Ship the specific translation before making a tailored recovery action necessary.

Connect errors to the translation workflow

Store API reasons, app resources, parameter definitions, and owners in a checked registry or schema. A wiki page will drift because neither the service build nor the app build reads it.

Compare the published server reason set with the app mapping in CI, but classify differences. A server-only reason may be acceptable when the client intentionally uses a generic fallback. An app-only reason can appear during a staged rollout. Block a launch when a required reason lacks source copy, a parameter contract, or a default-locale value.

Translators need the screen, action, parameter meanings, and failure condition. "Expired" could refer to a card, session, invitation, trial, or document. Each one calls for different recovery text. Attach the stable reason to the review record so later edits remain traceable.

Review server-localized messages on their own path. Record supported locale tags, fallback behavior, parameter formatting, and the owner responsible for changes. If both local and server copy arrive, the client must follow the declared precedence instead of choosing whichever string is present.

Test the copy and the behavior

Unit tests should verify mapping decisions, not only JSON decoding. For each known reason, exercise valid parameters, missing required values, extra unknown values, and the selected recovery action. A shared contract fixture can keep service and app teams on the same examples.

The localization matrix should include:

  • Every known reason in the default locale.
  • A structurally different locale for every parameterized message.
  • Relevant plural values, including zero, one, two, and many where needed.
  • Long text on narrow screens.
  • Right-to-left text mixed with dates, identifiers, and user names.
  • A reason introduced by a newer server.
  • Missing server-localized text for the requested locale.
  • Offline, timeout, malformed JSON, and gateway responses.
  • The behavior after retry, sign-in, or navigation.

Add an integration test that changes the diagnostic sentence while keeping the reason fixed. App-owned copy and client behavior must stay the same. Then send an unknown reason and confirm that the generic localized message appears without a crash.

When testing server-localized text, request several language tags and inspect the locale declared in the response. Text that is not English does not necessarily match the app's active locale.

Mistakes that should fail review

Parsing message is the most brittle option because an editorial change can alter program behavior. Returning only a translation key also fails as a general contract: external consumers may not own the catalog, and the key says nothing about parameters, severity, or recovery.

Sending both code and text without a precedence rule produces inconsistent screens. HTTP status alone is too broad because one 409 response can represent several conflicts that require different actions.

Neither side should own every message. App-only localization cannot explain policy introduced after the app shipped. Server-only localization removes ordinary interface copy from the app's review cycle. Classify each failure and record its owner.

An English string for every reason is not completion. The implementation also needs translations, typed parameters, fallback rules, recovery behavior, and tests against real response envelopes.

Verify the contract before release

Use this checklist during release review:

  • Every expected failure has a stable reason or problem type.
  • Client logic never branches on human-readable prose.
  • Each app-owned reason maps to a reviewed complete message.
  • Dynamic parameters have explicit types and locale-aware formatters.
  • Unknown reasons and malformed responses use a local fallback.
  • Developer diagnostics cannot appear directly in user-facing UI.
  • Server-localized text declares its locale and has documented precedence.
  • Recovery actions match both the failure and the operation semantics.
  • The oldest supported client handles newly introduced reasons safely.
  • Automated tests cover mapping, localization, fallback, and retry paths.

Pull the last twenty user-visible API failures from telemetry or support reports. For each one, assign a stable reason, presentation owner, message ID, parameters, and recovery action. Implement that bounded mapping, then add the contract test that changes server prose without changing the app's output. Passing that test proves the app no longer depends on English wording.

References