Errors & status codes
The status code tells a client whether to retry; the body tells it what to fix — and a client that reads only one of them gets stuck.
An error answers two different questions at once, and a client that reads only one of them gets stuck. The status code says whether to retry, and when. The body says what to fix, in codes stable enough to branch on.
The status code
- Whether this request may be sent again, and how long to wait
- Stable across releases, so branch on it
- httpStatusCode is a string, not a number
The body
- One errors entry per violating field, with its parameterName
- Branch on userMessageGlobalisationCode, never on the prose
- Messages get reworded and translated
Every Lending Core error is application/json in one envelope.
One envelope, one entry per bad field
{
"developerMessage": "Request was understood but rejected; inspect errors for field-level causes.",
"httpStatusCode": "400",
"defaultUserMessage": "The request could not be applied.",
"userMessageGlobalisationCode": "validation.msg.validation.errors.exist",
"errors": [
{
"developerMessage": "stateCode must be a full ISO 3166-2 code matching country.",
"defaultUserMessage": "stateCode is invalid.",
"userMessageGlobalisationCode": "validation.msg.address.stateCode.invalid",
"parameterName": "stateCode",
"value": "CA"
}
]
}
A form can highlight every violating field at once instead of walking a borrower through them one round trip at a time. Unknown body fields and unknown query parameters are rejected with 400 rather than silently discarded — a mistyped field name that was accepted and ignored is a value you believe you sent and the loan never saw.
The status says whether to retry
Ten rows, and the last column is the one your retry logic reads:
| Status | Meaning | Retry? |
|---|---|---|
200 |
Successful read, or a mutation’s command receipt {resourceId, changes}. |
No. |
201 |
Payment or funding created; Location names the new resource. |
No — a replayed create returns the original result with x-served-from-cache: true. |
400 |
Malformed request: unknown field or query parameter, invalid value, or a missing or invalid tenant header. | Fix request. |
401 |
Token missing, invalid, expired, or wrong environment. | Refresh once; then stop. |
403 |
Authenticated but missing the named permission, required case context, or valid actor-to-case relationship. | Change authority, not timing. |
404 |
Resource absent or outside tenant visibility. | Verify ID and environment. |
409 |
Lifecycle-state conflict, an in-flight idempotent duplicate (includes Retry-After), or a second entry for a SINGLE-mode form. |
Read current state, or wait Retry-After. |
422 |
Well-formed request violates a business rule, including an idempotency payload fingerprint mismatch. | Correct inputs, or follow the review path. |
429 |
Current rate budget exceeded. | Honor Retry-After; same key. |
5xx |
Server or dependency failure; outcome may be unknown. | Back off; same key. |
Not every 409 is a duplicate create
Three conditions arrive under the same status:
- in-flight idempotency: the same key’s original attempt is still executing, and the response carries
Retry-After; - state conflict: the operation is not valid from the resource’s current lifecycle state;
- single-entry conflict: a second entry for a SINGLE-mode form on the same target.
A reused key with a different payload on a money create is not a 409 — it is a 422 fingerprint mismatch. Blind retries cannot resolve a semantic conflict.
A 200 can carry bad news
A loan summary read can succeed while reporting that no payoff can be quoted:
{
"loanRef": "7204",
"payoff": { "available": false },
"asOf": "2026-09-20T10:00:00Z"
}
That is 200: the read model worked and the business answer is “not available right now.” A 503 means no reliable answer was produced, and the caller follows fail-closed degraded behavior. Collapsing the two — rendering a 503 as “no payoff available” — turns an outage into a statement, and the borrower is told something nobody computed.
Servicing shares the envelope, plus one smaller form
Implemented operations under /v1/servicing use the same response shape, with the same globalisation codes and parameter names. A request rejected at the shared tenant boundary returns the smaller safe form:
{
"code": "tenant.header.invalid"
}
Every servicing operation returns the same application/problem+json contract, so one parser handles the whole surface.
Keep the correlation IDs, drop everything else
Retain the request correlation identifiers your client and the response headers carry: they are how somebody finds your exact call among a day of traffic. Keep bearer tokens, raw personal data, provider credentials, full document payloads, and sensitive error inputs out of those same logs — a support report travels further than the request that produced it, and to more people.
The whole policy in seven lines
Tried in order — the first match wins
- 1429wait Retry-After plus jitter, same key
- 2Retryable 5xx or timeoutexponential backoff, same key
- 3409 with Retry-Afterwait, then resend it identically
- 4Any other 409read current state, decide again
- 5422show the field failure; looping cannot fix it
- 6401refresh the token once
- 7400, 403, 404fix request, authority, or identifier