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 mark | Decision |
|---|---|
| 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:
- Every hydrate/sync captures it from the same
LoadByRefNumberread it already makes (HydrateMlmLoanFields,HydrateMlmLeadLoanFields), so a real sync always leaves the mark current — even when no mapped field reconciled. - Independent-field writes opt in: after the write, a queued
CaptureMlmLastModifiedTimestampjob reads justsLastModifiedTimestampand 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:
| Facet | Where it is refreshed |
|---|---|
| Loan fields, borrowers, assets, liabilities, REO, employment, income, conditions, fees | HydrateMlmLoanFieldsJob (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.
Event-Driven Architecture
How domain events, listeners, and the workflow engine compose automation in the FlexPoint API.
Credit Ordering
How the portal orders a credit report through MeridianLink — the synchronous loan.save contract, asynchronous credit delivery via the change webhook, and the two-phase lead sync.