Documentation Index
Fetch the complete documentation index at: https://docs.api.bsa.ai/llms.txt
Use this file to discover all available pages before exploring further.
This page collects behaviors that aren’t always obvious from the
per-endpoint reference, drawn from live verification against the
upstream LMS. Each section ends with the actual error text or
response shape you’d see.
Loan state machine
A loan moves through five terminal states:
Submitted ─approve─► Approved ─disburse─► Active ─repay (full)─► Closed (obligations met)
│ ▲ │ │
│ │undo-approval │undo-disbursal └─over-repay──► Overpaid
│ │ │
├─reject ─────► Rejected (terminal)
└─withdraw ───► Withdrawn (terminal)
LMS enforces this strictly. Calling an endpoint against a loan that’s
in the wrong state returns HTTP 400 with a clear globalisation
code:
| Attempted action | Loan state | Error you get |
|---|
| Repay | Submitted / Approved | Loan must be Active, Fully Paid or Overpaid |
| Approve | Approved / Active | Loan is already approved |
| Disburse | Submitted / Active | Loan Account is not in approved and not disbursed state |
| Approve | Active | Loan Account is not in submitted and pending approval state |
| Reject | Approved / Active | Loan Account Reject is not allowed |
When you need to roll a state back, use the explicit undo endpoints —
/undo-approval and
/undo-disbursal. These work like time
machine: approval is rolled back to Submitted, disbursal to Approved.
Rejected and Withdrawn are terminal — there is no path back.
Dates: what LMS accepts on writes
All dates are ISO yyyy-MM-dd on the wire. The service translates to
LMS’s internal dd MMMM yyyy for you. Beyond that, LMS rejects two
classes of dates on transactions:
| Attempted date | Behavior |
|---|
| Earlier than the customer’s office transfer date (or loan disbursement) | 403 permission_denied — The date on which a repayment or waiver is made cannot be earlier than client's transfer date to this office |
| Future (any day after today, UTC at LMS) | 403 permission_denied — The transaction date cannot be in the future. |
| Malformed (not ISO) | 400 invalid_argument — must be ISO yyyy-MM-dd (caught by the service, never reaches LMS) |
Practical implications:
- Don’t pre-post repayments for collection batches that haven’t
settled yet — wait until the actual settlement date.
- Back-dating is allowed only up to the loan’s disbursement
date. Earlier-dated transactions are rejected.
Approving at a different amount than requested
POST /v1/loans/{id}/approve accepts an approvedLoanAmount that can
differ from the originally submitted principal. LMS reduces the
loan’s principal to the approved amount:
# Borrower submitted 10,000; underwriter approves 6,000
curl -X POST "$BASE/v1/loans/501/approve" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"approvedOnDate":"2026-05-23","approvedLoanAmount":6000}'
# Response (note: principal becomes 6,000, not 10,000)
{
"id": "501", "status": "Approved",
"principal": 6000, "approvedPrincipal": 6000, ...
}
The subsequent disbursement should be at the approved amount, not
the submitted amount.
Overpayments
If a single repayment exceeds the outstanding balance, LMS:
- Applies the right amount to settle the loan completely
- Records the excess as
overpaymentPortion on the transaction
- Transitions the loan to
Overpaid (not Closed (obligations met))
Example: outstanding is 11,100, customer pays 12,000:
{
"id": "22", "loanId": "11", "type": "Repayment",
"amount": 12000,
"principal": 10000,
"interest": 1100,
"overpaymentPortion": 900,
"reversed": false
}
The loan account then sits in Overpaid with totalOutstanding: 0 and
a 900 credit on the account.
The /refund endpoint does not clear
overpayment credits. Calling refund on an Overpaid loan returns:403 permission_denied — Loan Refund is not allowed. Loan Account is not active.
refundByCash (which is what /refund maps to) is for refunding
during an active loan’s life, not for cashing out overpayments.
Today there’s no public endpoint to settle an overpayment programmatically — the credit either gets applied to the customer’s
next loan or has to be resolved via a back-office process in LMS.
Repayment allocation: penalties → fees → interest → principal
The default strategy on every Halotel loan product applies each
repayment in strict order:
- Outstanding penalties
- Outstanding fees
- Outstanding interest
- Outstanding principal
If you don’t pass transactionProcessingStrategyCode on loan
creation, this is what you get.
This means a partial repayment that’s larger than the accrued interest
clears the interest entirely before reducing principal. Example loan
of 10,000 at 11% flat (interest = 1,100):
Payment 1: 2,000 → interest=1100, principal=900 (clears all interest)
Payment 2: 3,000 → interest=0, principal=3000 (pure principal)
Payment 3: 6,100 → interest=0, principal=6100 (closes the loan)
The first payment carries the full interest. Subsequent payments are
all principal until the loan is closed.
Reversal restores the balance exactly
POST /v1/loans/{id}/repayments/{txn}/reverse marks the transaction
reversed: true and the original principal/interest split stays
visible on the transaction record. The loan’s outstanding balance
recomputes as if the transaction had never happened.
Before any payment: totalOutstanding = 11,100
After paying 5,000: totalOutstanding = 6,100
After reversing: totalOutstanding = 11,100 ← restored
The original txn row is preserved (reversed: true) for the audit
trail. There is no way to delete a transaction — reversal is the only
“undo” mechanism for completed transactions.
Pagination uses query params page (1-indexed) and rows. Both have
strict caps; the service rejects out-of-range values with HTTP 400
before reaching LMS.
| Query | Behavior |
|---|
?page=1&rows=1 | OK — single-item page (smallest valid) |
?page=999&rows=10 (no data on this page) | OK — returns {"items":[],"total":N,"page":999,"rowsPerPage":10} |
?page=0 | 400 — page value too small, must be larger than 0 |
?rows=0 | 400 — rows value too small, must be larger than 0 |
?rows=1000 | 400 — rows value too large, must be less than 100 |
Upper bound on rows is 99, not configurable. Past-the-end pages
return an empty items array rather than 404 — check the total
field if you need to detect end-of-list.
Field-level validation errors
When you violate a field-level rule on a write, the response wraps the
list of failures in the message:
{
"code": "invalid_argument",
"message": "lms: status=400 code=\"validation.msg.validation.errors.exist\": Validation errors exist. [firstname: The parameter `firstname` is mandatory.; lastname: The parameter `lastname` is mandatory.]"
}
Parse from the first [ to read each rejected field independently —
see Errors → Field-level details
for the full pattern.
A worked example: full happy path
To pin all of the above together — a complete create-customer-borrow-repay-close lifecycle on the Halotel 7-Day product (11% flat
interest on 10,000 = 1,100 interest, total due 11,100):
# 1. Create customer (Person, active immediately)
CID=$(curl -sf -X POST "$BASE/v1/customers" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"officeId": 1, "legalFormId": 1,
"firstname": "Ada", "lastname": "Lovelace",
"mobileNo": "255700000000", "externalId": "ext-001",
"active": true, "activationDate": "2026-05-23"
}' | jq -r .id)
# 2. Submit loan
LID=$(curl -sf -X POST "$BASE/v1/loans" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{
\"customerId\": $CID, \"productId\": 1, \"principal\": 10000,
\"loanTermFrequency\": 7, \"loanTermFrequencyType\": 0,
\"numberOfRepayments\": 1, \"repaymentEvery\": 7, \"repaymentFrequencyType\": 0,
\"interestRatePerPeriod\": 11, \"amortizationType\": 1,
\"interestType\": 1, \"interestCalculationPeriodType\": 1,
\"transactionProcessingStrategyCode\": \"mifos-standard-strategy\",
\"expectedDisbursementDate\": \"2026-05-23\",
\"submittedOnDate\": \"2026-05-23\",
\"loanType\": \"individual\"
}" | jq -r .id)
# 3. Approve
curl -sf -X POST "$BASE/v1/loans/$LID/approve" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"approvedOnDate":"2026-05-23","approvedLoanAmount":10000}'
# 4. Disburse
curl -sf -X POST "$BASE/v1/loans/$LID/disburse" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"actualDisbursementDate":"2026-05-23","transactionAmount":10000}'
# 5. Repay in full (10,000 principal + 1,100 interest = 11,100)
curl -sf -X POST "$BASE/v1/loans/$LID/repayments" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"transactionDate": "2026-05-23",
"transactionAmount": 11100,
"paymentTypeId": 1,
"receiptNumber": "RCP-001"
}'
# Loan is now "Closed (obligations met)"