API error contract: returning meaningful errors to clients
How binding API error responses to a consistent contract simplifies client development and debugging.
The care put into designing successful responses in an API rarely carries over to error responses. When a client receives an error and doesn’t know what to expect, it either swallows everything behind a generic error message or learns each endpoint’s error format on a case-by-case basis. Neither is a good contract.
Errors need to be treated as an integral part of response design. If a successful response has a schema, the error response should have one too.
HTTP status codes are not enough
HTTP status codes matter, but they’re not sufficient on their own. 422 Unprocessable Entity tells you the request failed validation — not which field is wrong or why. 500 Internal Server Error tells you the server errored — not what kind of error or how.
The person building the client (you or someone else) needs additional context to interpret that status code. Providing that context in a standardized format in the response body is the contract’s job.
A consistent error structure
Here’s a structure I’ve tried across several projects and settled on:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Gönderilen veriler doğrulanamadı.",
"details": [
{
"field": "email",
"message": "Geçerli bir e-posta adresi giriniz."
},
{
"field": "phone",
"message": "Telefon numarası zorunludur."
}
]
}
}
There are three layers in this structure:
code: A machine-readable, fixed error code. The client can branch on this.message: A short human-readable description. Useful for debugging.details: Additional context when available. For validation errors, field-level messages; for other errors, an empty array or omitted entirely.
Implementing it in Laravel
In Laravel, I use the render method in app/Exceptions/Handler.php to manage all error responses from a single location:
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use Illuminate\Auth\AuthenticationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
class Handler extends ExceptionHandler
{
public function render($request, Throwable $e)
{
if ($request->expectsJson()) {
return $this->handleApiException($request, $e);
}
return parent::render($request, $e);
}
private function handleApiException($request, Throwable $e)
{
if ($e instanceof ValidationException) {
$details = collect($e->errors())
->flatMap(fn ($messages, $field) =>
collect($messages)->map(fn ($msg) => [
'field' => $field,
'message' => $msg,
])
)
->values()
->all();
return response()->json([
'error' => [
'code' => 'VALIDATION_FAILED',
'message' => 'Gönderilen veriler doğrulanamadı.',
'details' => $details,
],
], 422);
}
if ($e instanceof AuthenticationException) {
return response()->json([
'error' => [
'code' => 'UNAUTHENTICATED',
'message' => 'Bu işlem için kimlik doğrulama gereklidir.',
],
], 401);
}
if ($e instanceof HttpException) {
return response()->json([
'error' => [
'code' => 'HTTP_ERROR',
'message' => $e->getMessage() ?: 'İstek işlenemedi.',
],
], $e->getStatusCode());
}
return response()->json([
'error' => [
'code' => 'SERVER_ERROR',
'message' => 'Beklenmeyen bir hata oluştu.',
],
], 500);
}
}
Having a single method handle all exceptions means there’s only one place to look whenever you add a new error type.
A convention for error codes
I keep error codes uppercase and underscore-separated: VALIDATION_FAILED, RESOURCE_NOT_FOUND, QUOTA_EXCEEDED. This makes them processable on the client side via a switch-case or a map. The client team can show different messages to users or trigger different redirects based on these codes.
You don’t want to rely on the human-readable message field — that text can change and can be translated. The code is stable and is part of the contract.
Versioning and backward compatibility
Changing the error structure after it has been defined can be just as breaking as changing the successful response structure. If the client manages its own business logic by reading error.code, renaming that code causes a silent breakage on the client side. That’s why error codes should be treated as first-class citizens of an API contract: new codes can be added, existing ones should not be removed.
When you do need to deprecate a code, leaving a transition period and reflecting that in your documentation is good practice. It’s easy to forget error structure when versioning successful responses.
When it’s overkill
If you’re writing a small internal service with a single client and that client is you, this structure is unnecessary. A status code plus a short message may be enough.
But if you have multiple clients (web, mobile, an external partner), or if you’re exposing the API publicly, this contract genuinely simplifies the client development process. The time spent on “I got a 400, what does that mean?” questions accumulates into a significant cost.
Error responses are part of your API’s contract. They deserve just as much care in design as successful responses.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.