LendEasy/DocsLMS + Servicing·v1
Start integrating
Guides›Borrowers & Customers›Forms

Forms

A form keeps program questions out of the customer schema—published as an immutable version, validated on the server, and reachable only through permissions derived from the form's own key.

The customer profile answers “who is this party?” and should still answer it in ten years. A form answers something narrower and more perishable: the program questions of the moment—employment, income, hardship circumstances, application declarations. Keep them apart and each can change on its own clock. Merge them and the customer record grows a column for every program the lender has ever run, none of which anyone dares remove, because some loan somewhere was decided on it.

A draft is mutable; a publication never is

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 Structured 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.

Retiring stops new entries, not old reads

Publishing the pending draft creates an immutable publication and makes it the active version. A later publish replaces the active publication; a draft identical to the current publication publishes as a no-op.

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 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. One blanket forms permission would mean that the team collecting delivery preferences could also read hardship declarations, because both happen to be forms.

Field keys keep their types forever

{
  "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. A publication that changes an existing key’s type is rejected: turning gross_monthly_income from a decimal into free text would leave three years of stored answers undecodable by the code that has to read them. Server-side validation stays authoritative even when your own interface renders the same definition, because a browser is not where a lender wants a required income field to have been enforced.

An entry is accepted or rejected whole

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 response. Required active fields must be present and option codes must exist; a half-accepted entry would leave an underwriter reading a record that looks complete and is not. On a SINGLE-mode form, a second create for the same target returns 409 naming the existing entry, so a retried submission points the caller at the answers already on file instead of quietly filing a second income record next to them.

Branch on the code, not the message

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"
  }]
}

userMessageGlobalisationCode and parameterName are the stable pair. The human message is written to be read by a person and rewritten whenever it reads badly, so a client that parses it breaks on a copy edit.

A replacement is complete, never partial

  • GET /v1/customers/{customerId}/forms/{formKey}/entries and the loan twin list current entries with limit/offset paging, sort/order, and createdFrom/createdTo ranges (READ_LMS_FORM_<KEY>).
  • GET /v1/form-entries/{entryId} returns one entry with the answers for active fields; removed entries return 404.
  • PUT /v1/form-entries/{entryId} replaces the complete answer set: omitted optional fields clear, an identical replacement records no change, and an optional changeReason explains the update. The response is the rendered entry.
  • DELETE /v1/form-entries/{entryId} soft-removes the entry with a required reason (DELETE_LMS_FORM_<KEY>); it is idempotent and history is retained, because an approval granted on an entry somebody later removed still has to be explainable.
  • GET /v1/form-entries/{entryId}/history returns 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.
Use forms for structured facts and Notes for human context. A sentence in a note cannot satisfy a required form field or silently replace the answers a decision was based on.
Unified search across guides, recipes & the API referenceEsc