Forms
Define typed customer and loan intake forms with immutable publications, capture entries validated against the active schema, and gate access with per-form derived permissions.
The customer profile answers “who is this party?” A form answers a configured set of program questions such as employment, income, hardship circumstances, or application declarations. Keeping the two separate prevents every product-specific field from becoming permanent customer-schema clutter.
Definition and entry
| Record | Mutable? | Purpose |
|---|---|---|
| Definition draft | Yes, until published | The one working copy of a form’s name and schema. |
| Publication | Never | The immutable schema version entries validate against. |
| Form entry | Replaced as a whole | Typed answers for one customer or loan target. |
A stable formKey (lowercase, ^[a-z][a-z0-9_]{2,63}$) identifies the concept. A definition targets CUSTOMER or LOAN and declares an entryMode: SINGLE allows one current entry per target, MULTIPLE allows many.
Definition lifecycle
Definitions are managed under ADMIN_LMS_FORM_DEFINITION: POST /v1/form-definitions creates a draft, PUT /v1/form-definitions/{formKey}/draft replaces the single mutable draft in place, POST /v1/form-definitions/{formKey}/publish activates it, and POST /v1/form-definitions/{formKey}/retire (with a required reason) stops new entries while keeping existing ones readable. GET /v1/form-definitions/{formKey}/history records CREATED, PUBLISHED, and RETIRED events—draft saves are not events, and each publication event preserves the exact schema that was published.
Creating a definition atomically registers its derived permissions from the immutable key: READ_LMS_FORM_<KEY>, WRITE_LMS_FORM_<KEY>, and DELETE_LMS_FORM_<KEY>—for consumer_income, that is READ_LMS_FORM_CONSUMER_INCOME and siblings. Access is granted per form, not through one blanket forms permission.
Typed schema
{
"formKey": "consumer_income",
"targetType": "CUSTOMER",
"entryMode": "SINGLE",
"name": "Consumer income",
"schema": {
"sections": [{
"fields": [
{
"fieldKey": "employment_status",
"dataType": "SINGLE_SELECT",
"required": true,
"options": [
{ "code": "EMPLOYED", "label": "Employed" },
{ "code": "SELF_EMPLOYED", "label": "Self-employed" },
{ "code": "RETIRED", "label": "Retired" }
]
},
{ "fieldKey": "gross_monthly_income", "dataType": "DECIMAL", "required": true }
]
}]
}
}
Each field has a fieldKey and a dataType from TEXT, LONG_TEXT, INTEGER, DECIMAL, BOOLEAN, DATE, DATE_TIME, SINGLE_SELECT, or MULTI_SELECT, with options for the select types. Field keys keep their types forever—a publication that changes an existing key’s type is rejected—so historical entries always decode against a consistent registry. Server-side validation remains authoritative even if your UI renders the same definition.
Submit an entry
curl -X POST "$BASE/v1/customers/412/forms/consumer_income/entries" \
-H "Authorization: Bearer $TOKEN" \
-H "LendEasy-Tenant: demo-lender" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: maya-income-entry-v1" \
-d '{
"answers": {
"employment_status": "EMPLOYED",
"gross_monthly_income": "6400"
}
}'
{
"entryId": 5501,
"formKey": "consumer_income",
"targetId": "412",
"answers": {
"employment_status": "EMPLOYED",
"gross_monthly_income": "6400"
},
"createdAt": "2026-08-11T15:40:00Z",
"lastModifiedAt": "2026-08-11T15:40:00Z"
}
Requires WRITE_LMS_FORM_CONSUMER_INCOME; loan-targeted forms use POST /v1/loans/{loanId}/forms/{formKey}/entries. Unlike other LMS mutations, entry create and update return the rendered entry rather than a command envelope. The complete entry is accepted or rejected atomically—required active fields must be present and option codes must exist. On a SINGLE-mode form, a second create for the same target returns 409 naming the existing entry.
Validation failures
An invalid entry returns the LMS error shape with field-level causes:
{
"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": [{
"parameterName": "gross_monthly_income",
"defaultUserMessage": "A required field is missing.",
"userMessageGlobalisationCode": "validation.msg.validation.errors.exist"
}]
}
Clients should branch on userMessageGlobalisationCode and parameterName, not parse the human message.
Read, update, remove
GET /v1/customers/{customerId}/forms/{formKey}/entriesand the loan twin list current entries withlimit/offsetpaging,sort/order, andcreatedFrom/createdToranges (READ_LMS_FORM_<KEY>).GET /v1/form-entries/{entryId}returns one entry with typed answers for active fields; removed entries return404.PUT /v1/form-entries/{entryId}replaces the complete answer set: omitted optional fields clear, an identical replacement records no change, and an optionalchangeReasonexplains the update. The response is the rendered entry.DELETE /v1/form-entries/{entryId}soft-removes the entry with a requiredreason(DELETE_LMS_FORM_<KEY>); it is idempotent and history is retained.GET /v1/form-entries/{entryId}/historyreturns semantic events—created, answers updated with field-level before/after deltas, removed—newest first, so a decision can cite the values actually available when it ran.