Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

In one sentence: a good error response tells the caller three things, which are whether to retry, what to fix, and what to quote when they raise a ticket. Status code for the category, a stable machine-readable code for the specific condition, a human message for the developer reading logs, and a correlation ID for you. Everything else is decoration, and a stack trace is worse than decoration.

Pick a small set of status codes

Six or seven is plenty, and using them consistently matters far more than using them cleverly.

  • 400 the request is malformed. Bad JSON, missing required field, wrong type.
  • 401 no valid credentials. The caller should re-authenticate.
  • 403 authenticated but not allowed. Re-authenticating will not help.
  • 404 the thing you named does not exist, or you are not allowed to know that it does.
  • 409 a conflict with current state. Duplicate, version mismatch, already processed.
  • 422 well formed but semantically wrong. Business rules said no.
  • 500 our fault. The caller may retry.

The distinction between 400 and 422 is the one worth drawing. A caller can fix a 400 by changing the shape of the request. A 422 means the shape was fine and the content was rejected by a rule, which usually needs a human.

In Apex REST you set this explicitly, because an unhandled exception gives you a 500 with an unhelpful body:

RestContext.response.statusCode = 422;
RestContext.response.addHeader('Content-Type', 'application/json');
RestContext.response.responseBody = Blob.valueOf(JSON.serialize(err));

The body

Salesforce’s own error shape is a reasonable model to copy: an array of objects each with a message, an error code and the fields involved. Add a correlation ID and you have something genuinely useful.

{
  "requestId": "a1B5g000000XyzTEAU",
  "errors": [
    {
      "code": "ACCOUNT_NOT_FOUND",
      "message": "No account matches external key ERP-99213",
      "field": "accountExternalId",
      "retryable": false
    }
  ]
}

The code is the contract. Once ACCOUNT_NOT_FOUND ships, callers write branches on it and you cannot rename it. Choose names describing the condition, not the implementation, and keep a documented list. The message is for humans and may change freely. Nobody should ever be parsing it, and if you publish the code properly, nobody will.

The retryable flag is underrated. It saves every caller from maintaining their own list of which of your codes are transient, and it lets you reclassify without their client changing.

Partial success

Any endpoint accepting a list needs to say which items failed, not just that something did. Returning 400 for a payload of 200 records where one was bad forces the caller to guess or to resubmit everything.

{
  "requestId": "a1B5g000000XyzTEAU",
  "results": [
    {"index": 0, "status": "created", "id": "001..."},
    {"index": 1, "status": "failed",
     "errors": [{"code": "DUPLICATE_KEY", "message": "..."}]}
  ]
}

Return 200 for the envelope and let the per-item status carry the detail, or return 207 if your callers handle it. Either is defensible. What is not defensible is a single verdict for a batch of two hundred.

What not to include

No stack traces. No Apex class names or line numbers. No SOQL. No record IDs the caller has no access to. All of that tells an attacker about your internals and tells a legitimate caller nothing they can act on.

Instead, log the full detail internally against the correlation ID you returned. When someone reports a problem, they quote the ID and you have everything. This is the single change that most improves support conversations about an API.

Be careful with validation rule messages too. They are written for internal users and often name fields, processes and people the external caller should not see. Map them to your own codes rather than passing them through raw.

Frequently asked

Should I version error codes with the API? Treat adding a code as compatible and changing or removing one as breaking. Callers should be told to handle unknown codes by falling back to the status code.

What about rate limiting my own endpoint? 429 with a Retry-After header. Apex REST will not do it for you, so if you are counting requests yourself, return the header as well as the status.

Does the correlation ID need to be a record ID? No, but making it one is convenient, because it gives you somewhere to store the request, the response and the timings without inventing a lookup.

How do I stop an unhandled exception leaking? Wrap the whole method body in a try/catch, log the exception, and return your own generic 500 body. Never let the platform’s default error reach an external caller.