> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lms.bsa.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> Error envelope, the full list of error codes, and how to branch on them.

The API returns structured errors with stable codes. **Branch on the
`code` field, not the human-readable `message`.**

## Error envelope

Any non-2xx response has the following body:

```json theme={null}
{
  "code": "not_found",
  "sub_code": "customer_not_found",
  "message": "lms: status=404: Customer with id 9999 does not exist"
}
```

The HTTP status always matches the `code`. The `message` is a
human-readable string scoped to the partner contract — internal LMS
vocabulary (`Client`, globalisation codes like `error.msg.*`) is rewritten
or stripped before it reaches the wire, and it may change, so don't parse
it. `code` is an **error-only** field: successful responses return the
resource object (no `code`), and `204 No Content` has no body.

Some errors also carry an optional **`sub_code`** — a stable, finer-grained
business reason within a `code`. It lets you map an error to a specific
customer-facing message without parsing `message` (see
[Sub-codes](#sub-codes) below). `sub_code` is **additive and optional**: an
error may omit it, and new sub-codes may be introduced over time — so always
**branch on `code` first**, and treat an unknown or absent `sub_code` by
falling back to the `code`.

For validation failures (`invalid_argument`), the `message` is a
JSON-encoded array of `{field, error}` pairs:

```json theme={null}
{
  "code": "invalid_argument",
  "sub_code": "validation_error",
  "message": "[{\"field\":\"principal\",\"error\":\"must be greater than 0\"}]"
}
```

## Error codes

| HTTP | `code`                                     | When                                                                                                                                                                               |
| ---- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `invalid_argument`                         | Malformed or invalid input regardless of state — missing/empty/non-positive field, a date not in `yyyy-MM-dd`, a value too long. Field errors are listed in `message` (see above). |
| 400  | `failed_precondition`                      | The resource is in the wrong **state** for the operation — e.g. updating/deleting/adjusting an already-disbursed loan, or repaying a non-active loan. Fix the state first.         |
| 400  | `out_of_range`                             | A value is outside the valid range.                                                                                                                                                |
| 401  | `unauthenticated`                          | Missing, expired, or invalid bearer token. Refresh the token and retry.                                                                                                            |
| 403  | `permission_denied`                        | Authenticated, but the caller's role isn't authorized for this action. Refreshing the token won't help.                                                                            |
| 404  | `not_found`                                | No resource exists with that id or externalId.                                                                                                                                     |
| 409  | `already_exists`                           | A uniqueness constraint was violated — a re-used `externalId` on a customer, loan, or repayment (`idempotencyKey`).                                                                |
| 409  | `aborted`                                  | Conflict — a concurrency issue, or an operation blocked by the resource's current state (e.g. rolling back a loan that already has repayments).                                    |
| 429  | `too_many_requests` / `resource_exhausted` | Rate limit or quota exceeded. Back off and retry.                                                                                                                                  |
| 500  | `internal`                                 | Unexpected server-side or upstream failure. Detail is withheld from the response and logged.                                                                                       |
| 501  | `unimplemented`                            | The operation is not supported.                                                                                                                                                    |
| 503  | `unavailable`                              | A downstream dependency (core banking / credit service) is temporarily unreachable.                                                                                                |
| 504  | `deadline_exceeded` / `canceled`           | The request timed out (or was canceled) upstream.                                                                                                                                  |

## Sub-codes

When present, `sub_code` pins down the exact business scenario behind the
`code`. Each maps cleanly to a fixed customer-facing message — only
`validation_error` requires reading `message` (for the per-field detail).
Branch on `code` first; use `sub_code` to refine the message you show.

| `code`                | `sub_code`                       | Meaning                                                                                                                                                                                                                                       |
| --------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_argument`    | `validation_error`               | Request failed field validation. The offending fields are in `message` as a `[{field, error}]` array.                                                                                                                                         |
| `already_exists`      | `customer_external_id_exists`    | The customer `externalId` is already in use.                                                                                                                                                                                                  |
| `already_exists`      | `loan_external_id_exists`        | The loan `externalId` is already in use.                                                                                                                                                                                                      |
| `already_exists`      | `transaction_external_id_exists` | The repayment `idempotencyKey` is already recorded on a different transaction.                                                                                                                                                                |
| `not_found`           | `customer_not_found`             | No customer exists for the given id/externalId.                                                                                                                                                                                               |
| `not_found`           | `loan_not_found`                 | No loan exists for the given id/externalId.                                                                                                                                                                                                   |
| `not_found`           | `product_not_found`              | No loan product exists for the given id.                                                                                                                                                                                                      |
| `failed_precondition` | `loan_not_active`                | A repayment was attempted on a loan that isn't active.                                                                                                                                                                                        |
| `failed_precondition` | `loan_no_disbursement`           | An operation needing a disbursement (e.g. adjusting the disbursement date) was attempted on a loan that has none.                                                                                                                             |
| `failed_precondition` | `boost_not_eligible`             | The customer isn't eligible for a credit-limit boost.                                                                                                                                                                                         |
| `failed_precondition` | `domain_rule_violation`          | A business rule blocked the operation given the entity's current state (e.g. modifying an already-disbursed loan, closing a customer that still has active loans, a principal over the product maximum). The specific reason is in `message`. |
| `aborted`             | `rollback_has_repayments`        | A loan rollback was blocked because the loan already has repayments — reverse them first.                                                                                                                                                     |
| `aborted`             | `adjust_has_repayments`          | A disbursement-date adjustment was blocked because the loan already has repayments — reverse them first.                                                                                                                                      |
| `aborted`             | `loan_not_rollbackable`          | The loan's status doesn't permit a rollback (rollback is for tearing down a loan that never reached the customer, not a settled one).                                                                                                         |
| `permission_denied`   | `insufficient_role`              | The authenticated caller's role isn't permitted to perform the action.                                                                                                                                                                        |

## Idempotency (repayments)

Repayments accept an optional `idempotencyKey` (your wallet transaction
reference). Re-sending the **same key on the same loan** returns the
**original transaction with `200`** — a safe replay, not an error.
Re-using a key already recorded elsewhere (a different loan, or a race)
returns **`already_exists` (409)**.

## Retrying

| Code                                                                                                     | Retry?                                                                          |
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `unavailable`, `deadline_exceeded`, `too_many_requests`, `resource_exhausted`                            | Yes, with backoff. For non-idempotent calls (e.g. repayments), reconcile first. |
| `internal`                                                                                               | Yes once for idempotent calls, then surface to the caller.                      |
| `unauthenticated`                                                                                        | No — refresh the token, then retry once.                                        |
| `permission_denied`, `invalid_argument`, `failed_precondition`, `not_found`, `already_exists`, `aborted` | No — client-side fix; surface to the caller.                                    |

## Example: branching on errors

```python theme={null}
import json, requests

resp = requests.post(f"{BASE}/v1/customers", json=payload,
                     headers={"Authorization": f"Bearer {token}"})

if resp.ok:
    customer = resp.json()
else:
    err = resp.json()
    code = err["code"]
    sub = err.get("sub_code")            # optional, finer-grained
    if code == "invalid_argument":
        for fe in json.loads(err["message"]):
            print(f"  {fe['field']}: {fe['error']}")
    elif code == "already_exists" and sub == "customer_external_id_exists":
        show_customer("This ID is already registered")
    elif code == "unauthenticated":
        refresh_token_and_retry()
    elif code in ("unavailable", "deadline_exceeded", "internal"):
        retry_with_backoff()
    else:  # branch on `code`; refine the message with `sub` when present
        raise RuntimeError(f"{code}/{sub}: {err['message']}")
```
