The frontend team does not need a poetic error message from the API. It needs a stable branch. Can the user fix this? Can the client retry? Which field should be highlighted? What reference can support search for? When the response does not answer those questions, the UI invents behavior from status codes and string matching.
Here is a contract I have seen work well for product APIs:
{
"error": {
"code": "BILLING_ADDRESS_POSTAL_CODE_INVALID",
"message": "Check the postal code and try again.",
"retryable": false,
"requestId": "req_91ks2",
"fields": [
{
"path": "billingAddress.postalCode",
"message": "Postal code is not valid for the selected country."
}
]
}
}
The shape is not fancy. The value is that every field has one job.
Codes are for software, messages are for humans
The code is a product contract. The UI can map BILLING_ADDRESS_POSTAL_CODE_INVALID to a field state, analytics event, or help link. The message can change copy without breaking client behavior.
Do not make the frontend parse English. These are fragile branches:
if (error.message.includes("postal")) {
setPostalCodeError(error.message);
}
This is better:
for (const field of error.fields ?? []) {
form.setError(field.path, field.message);
}
The second version still shows human text, but it does not guess which input caused the failure.
Include retry guidance, not just status codes
Status codes are too broad for product behavior. A 409 might mean “the job already exists; open it” or “the idempotency key was reused with a different payload.” A 500 might be retryable, or it might represent a permanent configuration problem.
Add a boolean or documented code family:
{
"error": {
"code": "EXPORT_ALREADY_RUNNING",
"message": "An export is already running for this workspace.",
"retryable": false,
"requestId": "req_v2f1",
"resource": {
"type": "export",
"id": "exp_734"
}
}
}
Now the UI can link to the existing export instead of showing a generic failure toast.
For rate limits, include time:
{
"error": {
"code": "RATE_LIMITED",
"message": "Try again in a few minutes.",
"retryable": true,
"retryAfterSeconds": 120,
"requestId": "req_7c2a"
}
}
Request IDs reduce support guesswork
When a customer sends a screenshot, a request ID turns “it failed” into a log query. Put the ID in the response and make sure it appears in every downstream log line. If the API queues a job, log both the request ID and job ID.
The UI does not need to show reference IDs for every validation error. It should show them for unexpected failures, payment issues, imports, exports, and anything support may need to trace.
Validation paths should match the form
If the frontend field is called billingAddress.postalCode, return that path. Do not return customer.address.zip because that happens to be the backend model. The API contract belongs to the product interaction, not the database table.
Arrays need extra care. If the user can reorder line items, index-only errors become confusing. Include a stable row identifier when possible:
{
"path": "items[item_42].quantity",
"message": "Quantity must be at least 1."
}
Keep the error catalog small
A catalog with 300 codes nobody recognizes is not a contract; it is a junk drawer. Start with codes the UI handles differently. For example:
FIELD_INVALIDPERMISSION_REQUIREDRESOURCE_NOT_FOUNDEXPORT_ALREADY_RUNNINGPAYMENT_METHOD_DECLINEDRATE_LIMITEDTEMPORARY_PROVIDER_FAILURE
Add specific codes when they create a real product branch. Otherwise keep the diagnostics in logs.
Good API errors make the frontend calmer. They tell the UI what to do, tell support where to look, and leave backend details out of the user’s face.