FlexPoint Docs
Engineering

MLM Change Webhook & Freshness

How the portal stays current with MeridianLink — the inbound change webhook, the sLastModifiedTimestamp self-write guard, and what a webhook re-syncs.

MeridianLink (MLM) is the system of record. The portal keeps its local copy of a loan or lead current by reacting to MLM's outbound change webhook: MLM POSTs a batch of change entries whenever a file it hosts changes, and the portal decides, per entry, whether to pull fresh data.

The receiver is App\Http\Controllers\MlmLoanWebhookController, mounted at POST /api/v1/loans/mlm/webhook. It is bearer-authenticated with the shared MeridianLink client secret and pinned to MLM's egress subnets by AllowMlmWebhookIps. When no token is configured the endpoint is hidden (404).

The self-write problem

MLM fires a "Modified" webhook for every save — including the saves the portal itself makes. Without a guard, that creates a loop:

portal writes to MLM → MLM webhook fires → portal re-syncs → …

The guard is a high-water mark of MLM's sLastModifiedTimestamp. MLM stamps that value on a loan file every time it persists a change and emits the same value in the webhook entry (values.sLastModifiedTimestamp). The portal stores the newest value it already holds in loans.mlm_last_modified_at / leads.mlm_last_modified_at, and the rule is simply:

Webhook sLastModifiedTimestamp vs stored markDecision
not newer (≤ mark)a change we already have — our own write echoed back, or a duplicate delivery → skip
newer (> mark)a genuine external change → sync, then advance the mark

The helper is App\Support\Mlm\MlmLastModified. A high-water mark (not equality) is deliberate: it is precise — only the exact version we hold is suppressed, never an unrelated later edit the way a time-windowed flag would — it dedupes MLM's repeated batch entries, and it self-heals if a webhook ever beats our post-write capture (worst case: one harmless, read-only re-sync that then advances the mark).

Advancing the mark

The mark moves forward in two ways:

  1. Every hydrate/sync captures it from the same LoadByRefNumber read it already makes (HydrateMlmLoanFields, HydrateMlmLeadLoanFields), so a real sync always leaves the mark current — even when no mapped field reconciled.
  2. Independent-field writes opt in: after the write, a queued CaptureMlmLastModifiedTimestamp job reads just sLastModifiedTimestamp and advances the mark, so MLM's echo of that write is recognised as ours.

Why opt-in, not blanket

Suppressing the echo is only safe when the edited field has no MLM-side dependents. A borrower demographic edit (e.g. marital status) recomputes nothing, so it captures the mark and its echo is skipped. A write that makes MLM recompute (loan amount → LTV/DTI), a document upload, a program registration, or document generation deliberately does not capture the mark, so its webhook is newer than the mark and the recomputed data is pulled back. The lead-creation submit also seeds the mark so the creation webhook is not chased before the post–credit-reissue sync runs.

What a webhook re-syncs

A dispatched loan webhook refreshes the loan's mapped fields and collections:

FacetWhere it is refreshed
Loan fields, borrowers, assets, liabilities, REO, employment, income, conditions, feesHydrateMlmLoanFieldsJob (the mapped-field/collection hydrate)

HydrateMlmLoanFieldsJob is a thin orchestrator: it broadcasts that a deep fetch is in flight, then fans out into independent, individually-retryable HydrateMlmConcernJob slices (one per MlmHydrationConcern) on the lowest-priority loan-sync-assoc Horizon queue. Each slice is short and retries in isolation, so a single flaky collection never re-syncs the data already landed, and a higher-priority job can slip between slices. Ordering is enforced by self-orchestration rather than a Bus chain/batch — the scalar LoanScalarFields slice owns the is_hydrated/hydrated_at stamp, the hydrated broadcast, and the confirmed-deletion purge, while the borrower-scoped collections (assets/liabilities/employment, then REO/income) are dispatched by their upstream concern once borrowers carry the SSNs/lqb_ids they resolve against. Collection writes run with audit logging suppressed (a hydrate is a cache refresh from the system of record, not a user edit).

A webhook does not sync documents. A loan's (and prospect's) e-docs are reconciled live the moment a user opens its Documents tab, so there is no webhook-triggered, scheduled, or login-time document pull — documents are fetched on demand only for the record the user actually opens.

Leads refresh the same collections (conditions and fees included) through SyncMlmLeadData; leads have no document tab.

Observability

Every inbound entry is recorded as an MlmWebhookReceipt, surfaced on the Admin → Audit Log → MLM Webhooks view. Each row answers three questions: when the webhook arrived (received_at), whether and when our data was updated (data_updated_at), and which loan or lead it touched (loan_id / lead_id), along with the decision outcome (dispatched vs the specific skip reason). Receipts are pruned weekly by mlm:prune-webhook-receipts.

On this page