An error code is a contract, an error message is not

A client matching on the text of an error breaks when somebody improves the wording, translates it, or fixes a typo — and every one of those will happen.

{
  "error": {
    "code": "payment.card_declined",
    "message": "Your card was declined.",
    "retryable": false,
    "fields": { "card_number": ["invalid_checksum"] }
  }
}

The code is stable forever and the message is free to change, which is the entire idea and costs nothing when the first error is designed. retryable earns its place quickly: without it, every client invents its own rule about which failures deserve a retry button and those rules disagree with the server. Per-field codes rather than per-field sentences are what let a form highlight the right input in the right language.