0% прочитано

Safe API Errors: What to Expose to Clients and What to Keep for Diagnostics

An API error should be useful to the client without exposing internal system details. I show how I separate the stable public response, the technical failure reason, and a correlation ID used for diagnostics.

1 жовтня 2026 р. 8 хв читанняLaravel

When an API returns an error, there are two different needs.

The client needs to understand what happened and how the system classifies the failure. Internally, developers often need much more: the exact reason, failure location, exception, and technical context.

The problem begins when those two levels are mixed together.

While working on an external API in one of my projects, I made one rule explicit: an internal exception should not automatically become the public API response.

The external contract should remain stable and safe. Internal diagnostics can be much more precise.

A public error is part of the API contract. The text of an internal exception is an implementation detail. They are not the same thing.

An Exception Message Is Not an API Contract

PHP - Internal exception text leaks into the public response
try {    $service->execute($request);} catch (Throwable $exception) {    return response()->json([        'error' => $exception->getMessage(),    ], 500);}

This is convenient during early development, but it creates several problems for an external API.

An exception message may contain:

  • internal class or field names;
  • database structure details;
  • internal state;
  • a technical reason the client does not need to know;
  • data that should never cross the system boundary.

Exception messages also change easily during refactoring. If clients start depending on the text, an internal implementation detail silently becomes part of the external contract.

Externally I Return a Stable Problem Description

JSON - Generic example of a public API error
{  "type": "https://api.example.com/problems/request-conflict",  "title": "Request conflict",  "status": 409,  "detail": "The request cannot be completed in the current state.",  "code": "request_conflict",  "correlationId": "7efc7f19-..."}

The public response should contain only information that a client can safely depend on.

I treat several parts separately:

  • HTTP status describes the HTTP-level outcome;
  • a stable machine code lets clients distinguish failure classes programmatically;
  • a safe human-readable message explains the problem;
  • a correlation ID connects the response to internal diagnostics.

The example uses a structure similar to Problem Details. The actual codes and URLs are intentionally generic.

An Error Code Is More Stable Than Its Message

Messages are useful for people. Stable codes are more reliable for software.

For example, a message may change from:

The request cannot be completed.

to something clearer:

The request cannot be completed in the current state.

That should not change the client logic.

If the client depends on a stable code, the human-readable message can improve without breaking the integration.

HTTP Status and Error Code Serve Different Purposes

One HTTP status may represent several problem classes
HTTP 409    |    +--> request_conflict    +--> state_conflict    +--> duplicate_operation HTTP 422    |    +--> validation_failed

I do not use HTTP statuses as a custom internal error-number system.

409, 422, or 503 already have protocol-level meaning.

The stable application code then identifies the more specific problem class.

This keeps the external contract understandable both to normal HTTP tooling and to client code that needs more precise classification.

Sometimes a Less Specific Response Is Safer

There are failures where maximum precision in the public response does not help the client, but may help someone probing internal system state.

For example, a sensitive internal check may fail for several different reasons while intentionally producing the same public problem.

This is not hiding an error from the client. It is defining the correct information boundary.

The client receives enough information to decide what to do next. Internally, the system can retain a more precise reason for investigation.

The more security-sensitive an operation is, the more carefully I check whether each extra response detail is genuinely needed by the external client.

A Correlation ID Connects Both Sides of the Failure

Connecting the public failure to diagnostics
Client    |    | receives safe error    | + correlation ID    vAPI response correlation ID    |    v internal diagnostics    |    +--> technical reason    +--> exception    +--> request time

If the client should not see an internal exception, another question appears: how do I find that exact failure later?

I use a request correlation identifier for that purpose.

The client can include it in a support request or store it in its own logs. Internally, the same value can be used to locate the technical context of the request.

This avoids a bad choice between exposing internal details to the client and losing diagnostic traceability.

What Belongs in Internal Diagnostics

Internal diagnostics can be much more precise than the public response.

Depending on the system, they may include:

  • the correlation ID;
  • the internal failure or exception type;
  • the technical reason;
  • the occurrence time;
  • the operation that failed;
  • bounded context required for investigation.

This may be a normal application log, centralized logging, an error tracker, or another observability mechanism.

But internal logging should not become an uncontrolled dump of every piece of request data either.

Internal Logs Are Not a Place for Secrets Either

PHP - Too much request data in diagnostics
Log::error('API request failed', [    'exception' => $exception,    'request' => $request->all(),    'authorization' => $request->header('Authorization'),]);
PHP - Bounded diagnostic context
Log::error('API request failed', [    'correlation_id' => $correlationId,    'exception' => $exception::class,    'operation' => 'example_operation',]);

The fact that data is not returned to the client does not mean it should automatically be written to a log.

Raw tokens, passwords, secrets, full authorization headers, and other sensitive values can create a different kind of exposure through the logging system.

I therefore prefer logging the context needed to investigate the failure rather than dumping the entire request.

The API Boundary Maps Internal Failures to the Public Contract

PHP - The internal failure is deliberately mapped to a public problem
try {    $service->execute($input);} catch (WriteConflict $exception) {    report($exception);     return ProblemResponse::make(        status: 409,        code: 'request_conflict',        title: 'Request conflict',        detail: 'The request cannot be completed in the current state.',        correlationId: $correlationId,    );}

I prefer making this translation explicit in code.

The internal system reports the specific failure in its own language. The API boundary decides:

  • which HTTP status represents the situation;
  • which stable code is safe to expose;
  • which message is sufficient and safe;
  • which correlation ID belongs in the response.

This lets the internal model evolve without forcing external clients to understand every new exception class.

What I Check for Every New API Error

Before adding a new error to an external API, I check:

  • What does the client actually need to know?
  • Does the message expose internal state or implementation details?
  • Does the problem have a stable machine-readable code?
  • Does the HTTP status preserve its normal semantics?
  • Can the exact occurrence be traced through a correlation ID?
  • Are secrets excluded from both the response and internal logs?
  • Can internal code change without changing the external contract?

An API Error Is a Contract Too

It is easy to treat failures as a secondary part of an API and focus mainly on successful responses.

For an integration, however, an error is just as much part of the contract as a successful response.

The client needs a stable code, an appropriate HTTP status, and enough explanation. Developers need the precise cause and context required for investigation.

A correlation ID connects those two sides without exposing internal diagnostics publicly.

For me, a safe API error is not an error with as little information as possible. It is an error where every detail is placed on the correct side of the boundary.

The client needs a stable response. The developer needs the precise cause. A good API does not force you to choose only one.