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.
An Exception Message Is Not an API Contract
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
{ "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
HTTP 409 | +--> request_conflict +--> state_conflict +--> duplicate_operation HTTP 422 | +--> validation_failedI 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.
A Correlation ID Connects Both Sides of the Failure
Client | | receives safe error | + correlation ID vAPI response correlation ID | v internal diagnostics | +--> technical reason +--> exception +--> request timeIf 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
Log::error('API request failed', [ 'exception' => $exception, 'request' => $request->all(), 'authorization' => $request->header('Authorization'),]);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
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.



