How-to › Document and support developers

How to score your API's error messages with a rubric#

Sample fifty real error responses from the log, score each body 0 to 2 on six criteria, and publish the score per endpoint before anyone redesigns the errors.

Audience
API producer
Level
intermediate
Topic
Measure and audit developer experience
Verified

The team agrees the error messages are bad, but cannot say which endpoint is worst or whether last quarter’s fixes helped. The docs show tidy examples. The log shows a 400 whose body says Bad Request, a 404 that arrives as HTML from the gateway, and a 401 that is fine. The redesign is about to start from the examples.

What you get

You will end up with a rubric, a sampler that pulls fifty responses from the log, and a report that scores every endpoint as a percentage beside the three reference shapes. This is for you if you own an API and want a number before the redesign.

Short answer

Pull fifty error responses from your logs with a spread across endpoints and status codes. Score each body 0 to 2 on six criteria: field named, fault described, fix given, stable code, documentation link, and the API’s dominant shape. The score is a percentage of the points that could apply. Leave a criterion out of the denominator where the status makes it meaningless, report the percentage per endpoint, and re-score after every release.

You will need

Node 22 or later, and an export of error responses from your gateway or service logs with the route template, the status, the response headers, and the body of each. Verified 2026-10-21 against Node 22.22.0. The sample runs on a constructed log of 260 error responses across nine routes. The shapes are what a real API accumulates: problem details on two routes, a Stripe-shaped envelope on two, a Google-style Status body on one, and bare messages or HTML on the rest. The rubric’s wording criteria take their definitions from the NN/g error-message guidelines, which ask for human-readable language, a precise description of the issue, and constructive advice.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Google AIP-193 error modelA gRPC or Google-style API where errors already travel as a Status with a details listA details list to walk, an ErrorInfo with a reason in upper snake case and a domain, and canonical codes to mapYour API speaks plain HTTP status codes and a flat JSON body
Nielsen Norman Group error-message guidelinesScoring the words: whether the message is human-readable, precise, and constructiveWritten for interface text, so the machine-readable half of the rubric, codes and links, gets nothing from itYou are scoring shape rather than wording
RFC 9457 problem detailsA standard shape with a registry and a media type, and clients that already parse itFive members and a type URI you must mint and host, with the fix left to extensions the RFC does not nameThe API already has an error envelope every client depends on
Stripe error objectA flat envelope with type, code, message, param, and doc_url, which is most of the rubric in five membersA shape owned by one company, with a code list you have to write and maintain yourselfYou want a type URI clients can dereference rather than a code list to publish

The three shapes score nearly alike on the rubric and differ in what they cost. Problem details come with a media type and a registry,2 and leave the remedy to extensions, so a generic scorer cannot find it. The Google model carries a reason and a Help link inside a details list that every client has to walk. Stripe’s object is the flattest, and it is one vendor’s convention. The NN/g guidelines score none of that. They score the sentence a developer reads.

Pull fifty responses from the log, not from the docs

The docs examples are the errors someone designed. The log holds the errors the API returns, including the ones a gateway or a framework writes on its behalf. Export the error responses with the route template, the status, the headers, and the body, then let sample.mjs pick fifty. It groups the log by route and status and takes at most four from each cell in hash order. Every cell keeps its first, and the rest of the fifty places go by hash. The same log gives the same sample on any machine.

export function cells(log) {
  const out = new Map()
  for (const e of log) {
    const key = `${e.route} ${e.status}`
    if (!out.has(key)) out.set(key, [])
    out.get(key).push(e)
  }
  return new Map([...out].sort(([a], [b]) => byCodePoint(a, b)))
}

/** Up to PER_CELL from every cell. Each cell's first by hash is kept, so a rare status is never
 * cut, and the places left go to the rest by hash, so a small log still fills TARGET. */
export function sample(log, { perCell = PER_CELL, target = TARGET } = {}) {
  const firsts = []
  const rest = []
  for (const [, entries] of cells(log)) {
    const [first, ...others] = [...entries].sort(byRank).slice(0, perCell)
    firsts.push(first)
    rest.push(...others)
  }
  const picked = [...firsts.sort(byRank).slice(0, target), ...rest.sort(byRank)].slice(0, target)
  return picked.sort((a, b) => byCodePoint(a.id, b.id))
}
node sample.mjs
log: 260 error responses, 9 routes, 10 status codes
sample: 50, at most 4 per route and status

route                     400  401  402  403  404  409  422  429  500  502    n
GET /v1/customers/{id}      .    .    .    .    4    .    .    .    .    .    4
GET /v1/invoices/{id}       4    .    .    .    4    .    .    .    .    .    8
GET /v1/reports/{id}        .    .    .    1    .    .    .    4    .    .    5
GET /v1/webhooks            .    .    .    .    3    .    .    .    .    1    4
POST /v1/auth/token         .    4    .    .    .    .    .    .    .    .    4
POST /v1/customers          4    .    .    .    .    .    .    .    .    .    4
POST /v1/exports            4    .    .    .    .    .    .    .    .    .    4
POST /v1/invoices           .    .    .    .    .    2    4    .    .    .    6
POST /v1/payments           3    .    4    .    .    .    .    .    4    .   11

The grid is the first finding. Nine routes answer in ten status codes, and POST /v1/payments fails in three of them. GET /v1/webhooks returns a 502, which no code of yours wrote. A rare cell keeps a place: GET /v1/reports/{id} answered 403 five times in the whole log, and the cut to fifty still keeps one of them.

Score each body on six criteria, 0 to 2

The rubric is a file, so a second reviewer scores against the same words:

    { "id": "field", "name": "names the offending field or parameter",
      "applies_to": [400, 404, 409, 422],
      "levels": { "0": "no field is named", "1": "a field is named in the prose only", "2": "a field is named in a machine-readable member" } },
    { "id": "what", "name": "says what was wrong",
      "levels": { "0": "no message, or only the status phrase", "1": "a message that could be about any request", "2": "a message specific to this occurrence" } },
    { "id": "fix", "name": "says how to fix it",
      "levels": { "0": "no hint", "1": "a hint outside the body, such as Retry-After", "2": "the body says what a correct request looks like" } },

Each criterion is a function in score.mjs that reads the parsed body and the headers. The status line decides only which criteria apply: a 401 or a 429 has no offending field, so field is left out of its denominator rather than scored 0. That is the trade-off a strict rubric has to make. A bare 401 with a stable code can still reach 100%, and a 400 whose body says only Bad Request cannot.

  what(entry, parsed) {
    const m = messageOf(parsed)
    if (!m) return 0
    const generic = m.toLowerCase() === (REASON[entry.status] ?? '').toLowerCase() || m.split(/\s+/).length < 4
    return generic ? 1 : 2
  },
  fix(entry, parsed) {
    const m = messageOf(parsed)
    const help = parsed.kind === 'json' && detail(parsed.body, 'Help')
    if (help || FIX_WORDS.test(m)) return 2
    return entry.headers?.['retry-after'] ? 1 : 0
  },
node report.mjs
sample: 50 responses; dominant shape json:error

route                     n  score   field  what   fix  code  docs shape
GET /v1/customers/{id}    4    83%     2.0   2.0   0.0   2.0   2.0   2.0
GET /v1/invoices/{id}     8    75%     1.0   2.0   1.0   2.0   2.0   1.0
GET /v1/reports/{id}      5    24%     n/a   1.0   0.8   0.2   0.0   0.4
GET /v1/webhooks          4     0%     0.0   0.0   0.0   0.0   0.0   0.0
POST /v1/auth/token       4    30%     n/a   1.0   0.0   1.0   0.0   1.0
POST /v1/customers        4    83%     2.0   2.0   0.0   2.0   2.0   2.0
POST /v1/exports          4   100%     2.0   2.0   2.0   2.0   2.0   2.0
POST /v1/invoices         6    81%     1.3   2.0   1.3   2.0   2.0   1.0
POST /v1/payments        11    19%     0.0   1.0   0.0   0.0   0.0   1.0
all                      50    52%

lowest: GET /v1/webhooks at 0%, for example 502 "<html><body><h1>502 Bad Gateway</h1></body></htm"

reference shapes, one example each, shape left out
  RFC 9457 problem details     6/8
  Google AIP-193 Status        10/10
  Stripe error object          8/10

Read the table by column before by row. fix is the weakest column: five routes score 0 on it, including the two Stripe-shaped routes that pass everything else, because Missing required param: email. names the field but not what a valid one looks like. shape gives the problem-details routes a 1, because this API’s dominant shape is the error envelope, and consistency is measured against your own API rather than against the RFC. POST /v1/payments scores 19% on eleven responses, the most of any route in the sample, which makes it the first endpoint to fix. POST /v1/exports scores 100% with a reason of INVALID_FORMAT and a Help link.3 The example in RFC 9457 scores 6 of 8, because its remedy sits in an extension member that a scorer cannot know is a remedy.4

Check it worked

Eleven tests pin the rubric with node:test. Two matter most. The terse 401 keeps a denominator of 10 rather than 12. The status line alone gives every route the same score within two points, while the bodies spread from 0% to 100%. The last three keep a rare cell in the sample, draw the same sample under a Danish locale, and read a JSON string body as a message.

node --test score.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 380.488389

If # pass 11 drops after an edit to rubric.json, a criterion lost a level or changed its id.

When it goes wrong

Every route scores the same. The scorer read the status line and ignored the body, which is where all six criteria live. A status line carries a code and a reason phrase, and a reason phrase is one point on one criterion:

node pitfall.mjs
scored on the status line only
  GET /v1/customers/{id}     8%
  GET /v1/invoices/{id}      8%
  GET /v1/reports/{id}      10%
  GET /v1/webhooks           9%
  POST /v1/auth/token       10%
  POST /v1/customers         8%
  POST /v1/exports           8%
  POST /v1/invoices          8%
  POST /v1/payments          9%

scored on the body
  GET /v1/customers/{id}    83%
  GET /v1/invoices/{id}     75%
  GET /v1/reports/{id}      24%
  GET /v1/webhooks           0%
  POST /v1/auth/token       30%
  POST /v1/customers        83%
  POST /v1/exports         100%
  POST /v1/invoices         81%
  POST /v1/payments         19%

status line: every route from 8% to 10%; body: from 0% to 100%
criteria read from the status line: 0 of 6

The two points of spread in the first table come from the denominator, because field does not apply to a 401 or a 429. Everything that distinguishes a route is in the second table.

The problem-details routes score lower than the bare-message routes on shape. The dominant shape is whatever most of the sample uses, and on this API that is the error envelope. Read the column as a measure of consistency rather than of merit, and settle which shape wins in the design work that follows, with one schema for every error response.

Two reviewers score the same body differently. The levels in rubric.json are too loose, or one reviewer read the docs example instead of the log entry. Score a shared ten first, compare, and tighten the level wording before anyone scores the other forty.

When not to do this

Do not score the docs examples. They are the errors someone designed, and they score well because they were written to be read. The sample comes from the log or it measures nothing.

Do not redesign from the score. The rubric says which endpoint is worst and on which criterion. The design of the replacement belongs to the error-handling pages, starting with validation errors a client can map to a form.

Do not report the overall 52%. Eleven payment responses at 19% and four export responses at 100% average to a number no endpoint has. Publish the per-route table, and re-score the same fifty cells after each release so that the table has a direction.

Do not add a seventh criterion for tone. Tone is the criterion two reviewers disagree on most, and six criteria are enough to rank endpoints.

Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed. The log is a constructed fixture of 260 responses, not a real API’s quarter. The RFC 9457 reference body is the example printed in the RFC. The AIP-193 and Stripe reference bodies are built from each shape’s documented members with one illustrative error each. No Google or Stripe endpoint was called.

Footnotes

  1. The NN/g guidelines date from May 2023. They open by tracing themselves to the ninth of Nielsen’s ten usability heuristics, from more than thirty years earlier: help users recognize, diagnose, and recover from errors. Recognize, diagnose, and recover are this rubric’s what, code, and fix criteria, with a denominator attached. ↩︎ Back to text

  2. RFC 9457 created an HTTP Problem Types registry at IANA, with a Specification Required policy. The registry holds six entries. One is about:blank, registered by the RFC itself to say that the status code is the whole message. The other five recommend status 400, and three of them describe what can go wrong with a digest. No problem type for a missing field has been registered. ↩︎ Back to text

  3. AIP-193 wants the reason in upper snake case, with no underscore at either end and no digit in front. It gives THE_BOOK_YOU_WANT_IS_NOT_AVAILABLE as a bad example for being overly verbose and ERROR as a bad example for being too general, so the width of a good reason is set by two bad ones. ↩︎ Back to text

  4. The example in RFC 9457 is a 403 for an account out of credit. Its remedy sits in an extension member named accounts, which the text beneath it says lists links where the account can be topped up. The scorer here reads prose and the five named members, so it finds no remedy. The RFC also tells consumers not to parse detail for information, so the one place the rubric looks for a fix is the one place the specification says not to read. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.