> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Underwriting workflow trigger

# Underwriting Completed marketplace workflow trigger

Internal runbook. Operator-facing steps live in
`docs/operators/getting-started/underwriting-completed-trigger.mdx`.

## What it is

A marketplace workflow trigger, key `underwriting_completed` ("Underwriting
Completed"), that starts an Operator's CRM workflow with the Contact's current
underwriting result as custom variables. The definition is
`docs/internal/marketplace-triggers/underwriting-completed.json`.

The payload is a read-only projection of `underwritingResults`
(`convex/lib/underwritingWorkflowTrigger.ts`). It never feeds back into the
decision, so the one-engine Locked Decision is untouched. `outcome` is the
same `deriveAutomationKey` every notification uses. `fundingPaths`,
`qualifyingProducts` and the overlay flags read the stored `workflow`, the
engine's routing result. The decision, status and range fields carry operator
overrides, and `reviewStatus` carries the disposition. A revise patches only
`operatorOverrides` and never rebuilds `workflow`, so on a file with a stored
workflow `outcome` keeps the engine's routing (see
[Known limitations](#known-limitations)).

`credit_not_found` ("Credit not found") is read from status codes. The engine
stores no `workflow` for a file with no credit file (`deriveWorkflowFromProducts`).
After the manual-review check, a row with no stored workflow whose engine or
effective card status (`operatorOverrides.cardsStatusCode ?? cardStatusCode`) is
`credit_not_found` is projected from its codes:

* With no typed override code, including after a notes-only revise or an override
  string the revise handler cannot classify, the outcome is `credit_not_found`.
* With one, the effective card and term loan codes (override ?? engine) and the
  stored Revenue Based verdicts are rebuilt into the `{primaryPaths, overlays}` that
  `deriveWorkflowFromProducts` stores for them, using the same `underwritingStatus`
  predicates, and routed through the one existing ladder. Revenue Based is
  evaluated on every file, including Credit Not Found, and the revise dialog cannot
  change it, so the stored `products` verdicts are the effective ones: any MCA, SBA
  or BLOC product at `qualified` or `qualified_conditional` adds
  `established_funding` after the term loan and card paths, with no overlay. Rows
  without stored `products` have no Revenue Based path. No second routing path is
  needed: paydowns over inquiry removal, seasoning parking, the term-loan inquiry
  overlay, Revenue Based funding and multi-track all come from the same code. As in
  the engine, the card verdict decides first: a card still at Credit Not Found stays
  `credit_not_found` and a card in manual review is `manual_review`, whatever the
  term loan or Revenue Based verdicts say. With no qualifying path, a term loan in
  manual review is `manual_review`, and anything else is `declined_no_fit` (these
  rows carry no repair-referral signal). Both senders project `products` (narrowed
  to `{product, status}`) so the send-time outcome matches the queued one.
  `tests/automationOutcome.test.ts` checks every card × term loan × Revenue Based
  cell against `deriveWorkflowFromProducts`.

Every other null-workflow row still falls through to `qualified`, as before.
Client notifications for this outcome are off by default:
the legacy trigger mode is `draft_only` and the Notification Center catalog mode is
`"off"`. Both senders re-derive the outcome just before sending and skip a send
whose outcome changed (`result_changed`), so a `qualified` send queued for a
Credit Not Found result before deploy never goes out.

`GET /api/v1/contacts/{contactId}/underwriting-result` (and the MCP tool, both
`handleGetUnderwritingResult`) reports the same operator-reviewed values through
`effectiveUnderwritingValues`: the decision, status and range fields plus
`outcome`, `outcomeLabel` and `reviewStatus`. No field was removed or renamed, and
`declineReason`, `mcaPossible`, `sbaPossible` and the product fields stay raw. It
reads the Contact's newest row in the Location by `createdAt` through
`internal.underwriting.getLatestUnderwritingResultForContactInternal`, on the
`by_contact_location_createdAt` index the trigger's newest-row check uses, so it
never reads another Location's rows; the handler still checks the row's
`locationId`. Sample files stay
readable there, because it is a read of what the Operator already sees in the app;
the trigger skips samples because it starts outbound workflows.

## Lifecycle

1. **Subscribe.** When an Operator saves a workflow that uses the trigger, the CRM
   POSTs to `POST /api/marketplace/underwriting-trigger` with
   `triggerData.{id,key,filters,eventType,targetUrl}` and
   `extras.{locationId,workflowId,companyId}`. CREATED/UPDATED upsert a
   `workflowTriggerSubscriptions` row keyed by `triggerId`; DELETED removes it. A
   `triggerId` never moves between Locations. Only
   `https://services.leadconnectorhq.com/workflows-marketplace/triggers/execute/...`
   is accepted as a `targetUrl`, because we send the Location's access token there.
2. **Queue.** Every write to a result schedules
   `internal.underwritingWorkflowTrigger.queue`: the engine store
   (`storeResultsHandler`), the operator review handlers (revise, approve, approve
   Revenue Based, revoke, park, decline, selected products, which also covers
   `confirmFundingPlan`) and target utilization. `queue` returns early when the
   Location has no subscription, the row is a sample or has no Contact, or the row is
   not the Contact's newest result. It then compares the payload (minus
   `completedAt`) with `underwritingTriggerStates` and only schedules `deliver` when
   something changed. That keeps the one-click-many-writes paths
   (`confirmFundingPlan`) and no-op re-runs from billing Operators for duplicate
   premium executions.
3. **Fan out.** `fanout` pages through the Location's subscriptions 50 at a time
   and schedules one `deliver` per subscription whose stored filters match
   (`triggerFiltersMatch`). There is no cap on subscriptions, and a slow workflow
   endpoint cannot push the others past the action time limit. See
   [Filters](#filters) for the shapes it reads.
4. **Deliver.** Before every POST, including an inline retry and the resend
   after a token refresh, `deliver` re-reads the subscription. It drops the send
   if the subscription was deleted or no longer matches, or if the Contact's
   state has moved to a newer `generation` by then (logged as `"dropped"`).
   Otherwise it POSTs to the subscription's current `targetUrl`, so a workflow
   re-saved while a send waited gets it at its new URL. Each queued change gets a
   new generation, even when the payload returns to an earlier one, so an old
   retry cannot resend after A → B → A. If that read itself fails, nothing is
   sent and the send is retried like a 503.
   The POST carries `Authorization: Bearer <location access token>` and
   `Version: 2021-07-28`. A 401 refreshes the token once; a 404/410 removes the
   subscription unless it was re-saved with a different `targetUrl` after the
   POST.
5. **Retry only what clearly did not run.** Premium executions are billed per run,
   and the execute endpoint has no documented idempotency key. We resend only when
   the workflow certainly did not start: no token, a token refresh that failed or a
   failed subscription read (nothing was sent), 401, 429 and 503. Those retry twice inline, then
   again after 1 minute, 10 minutes and 1 hour for that one subscription. A 500,
   a gateway error (502, 504), a timeout or a dropped connection may have come
   after the workflow started, so they are logged (`"unknown"` for no response)
   and never resent automatically. Any other status
   (for example 400) is final. The next change to the result sends again.

## Filters

The definition offers one filter, **Outcome** (`underwriting.outcome`,
multiselect). Two stored shapes were observed live (see
[Live test](#live-test-2026-10-14)):

* filtered to Qualified:
  `{"field":"underwriting.outcome","operator":"is-any-of","value":["qualified"]}`
* no filter: `filters: []`

The CRM's format is otherwise undocumented, and its negation spelling has not been
observed. `triggerFiltersMatch` accepts the observed shape plus listed hypothetical
spellings and fails closed on everything else. It checks the field, then the
operator, then the value:

* **Field:** only `underwriting.outcome` or `outcome`. Any other field is a
  `"field"` failure, even with an empty value. `contains`, `does_not_contain` and
  the like mean Outcome membership; nothing is compared as a scalar.
* **Operator:** spelling and case are normalized (`is-any-of`, `isAnyOf` and
  `IS ANY OF` are all `is_any_of`), then looked up in two explicit lists in
  `convex/lib/underwritingWorkflowTrigger.ts`, with no wildcards. The positive list
  includes empty, `=`, `eq`, `equals`, `is`, `in`, `is_any_of`, `any_of`, `one_of`
  and `contains`. The negated list includes `!=`, `<>`, `ne`, `nin`, `not_in`,
  `is_not`, `is_not_any_of`, `none_of`, `does_not_contain` and `excludes`, and
  inverts the match. Any other spelling, such as `not_starts_with`, is an
  `"operator"` failure.
* **Value:** a string, a comma list, a JSON array string, `{value}` or
  `{label,value}` objects, or an array of those, where every selected value is an
  outcome key. A deliberately empty selection (no value, `null`, `""`, `[]` or
  `"[]"`) matches every result. A `{value: null}` or label-only object, an array
  member that is empty, null or an object without a value, a comma list with an
  empty member (`","` or `"qualified,"`), non-array JSON, or a value that is not
  an outcome key
  (such as the label `Qualified`) is a `"value"` failure.

Anything that fails is not matched: the workflow is not started for that change.
`fanout`, and the filter re-check `deliver` makes before each POST (the workflow may
have been re-saved since fanout), log one warning per evaluation:

```
[Underwriting Trigger] Unrecognized workflow filter; workflow not started
{ locationId, workflowId, reason: "field" | "operator" | "value", field, operator, valueType, valueKeys }
```

It carries the filter's field name, operator string, value type and object key
names, never the selected values or any Contact data. When it appears, read the
stored `workflowTriggerSubscriptions.filters` to see the new shape before adding
support for it.

## Live test (2026-10-14)

Location `oE9ILHco0XXZUu79wAA0`, against deployed main:

* Two workflows: `a568cd3e-0fa1-458b-b79d-3e450fd473b1` (no filter) and
  `8c1750d3-cf2a-4330-802e-89353f66f623` (Outcome filtered to Qualified). Both are
  back in Draft.
* Saving them sent subscription POSTs that returned 200. Prod has both
  `workflowTriggerSubscriptions` rows, scoped to the Location, with the
  [observed filter shapes](#filters).
* A notes-only revise on a test Contact's Credit Not Found file was delivered: 201
  at about 20:51:07Z, with `outcome: "qualified"` and `reviewStatus: "pending"`.
  That outcome was the baseline bug the Credit Not Found projection fixes; the
  stored row had `underwritingDecision: "Credit Not Found"`,
  `cardStatusCode: "credit_not_found"`, `workflow: null` and no decision overrides,
  before and after the revise. `tests/underwritingWorkflowTrigger.test.ts` replays
  that shape.
* Contact attachment works. The workflow's Find Contact step was skipped with
  "Only one contact is allowed per workflow, and Test Applicant1 is already in use",
  and the contact field update after it ran.

### The trigger was missing at first

Before the test, Underwriting Completed was missing from the Location's workflow
builder although the install (live app version 7.0.1), its scopes, premium
triggers and the published, active trigger were all in place. The builder's
installed-module catalog request for the Location returned `[]`. This was a
provider catalog/registration discrepancy, not a premium, scope or version problem.
Kevin resolved it in that Location by uninstalling and reinstalling the app, after
which the trigger appeared. That a reinstall worked there does not prove it is the
only path for other installs.

The provider's app-update guide
([https://marketplace.gohighlevel.com/docs/oauth/HowToUpdateYourAPP](https://marketplace.gohighlevel.com/docs/oauth/HowToUpdateYourAPP)) says: "Modules
do not automatically update for existing installs. Users must manually update or
reinstall." Module changes ship in a Major app version, and each existing install
must confirm that update. The operator guide therefore says to accept the app's
**Update** offer first and to reinstall only as a fallback.

What an uninstall does on our side (`app/api/lb/webhook/route.ts` UNINSTALL →
`deleteInstallation` in `convex/ghlInstallations.ts`): it deletes the Location's
`ghlInstallations` token row and cancels any pending processing-application
new-install gate. Installation history is preserved, and contacts, underwriting
reports and settings are not touched.

## Payload

```json theme={null}
{
  "contactId": "...",
  "contact": { "id": "..." },
  "underwriting": { "outcome": "qualified", "outcomeLabel": "Qualified", "...": "..." }
}
```

The full field list is `UnderwritingTriggerPayload` and the trigger's
`customVarsJson`. Adding a field later means updating both and publishing a new
trigger version (below). `tests/underwritingWorkflowTrigger.test.ts` checks that
the definition's sample is a payload the builder really produces, that its Outcome
options are `TRIGGER_KEYS` with their labels, that every field is a custom variable
of the right type, and that the operator guide shows the same sample.

## Environment

* `GHL_UNDERWRITING_TRIGGER_TOKEN`: static bearer the CRM sends on subscription
  callbacks. Generate with `openssl rand -hex 32` into a 0600 file outside the repo.
  Set it on Vercel (Production, Preview, Development; Sensitive) before the trigger is
  pushed, then redeploy. The pushed header is `Bearer <token>` from
  `GHL_UNDERWRITING_TRIGGER_AUTHORIZATION` at push time.
* The proxy exempts the exact path `/api/marketplace/underwriting-trigger`
  (`lib/proxy/isExemptPath.ts`); the route verifies the bearer itself.

## Marketplace configuration with the official CLI

Same isolation rules as `new-lead-marketplace-action.md`: isolated `GHL_CONFIG_DIR`,
scratch directory outside the repo, only `ghl app triggers ...` sub-commands, never
`ghl app push`. `workflows.readonly` is already in the app's scopes.

### Versions

Version 1.0 is published (`templateId` `01M4XT0RM1WD6R8SFHPMZM4G99`) with the
content at FM-2842 (#1934): nine Outcome options and placeholder sample values.
Published versions cannot be edited. The repo JSON keeps the server-owned fields
(`templateId`, the latest published `version` and its `status`) in step with the
portal, while its editable fields (`info`, `customVarsJson`, `filters`,
`customVars`) define the next version: the Credit not found option and sample
values the payload builder really produces.

Until that version is published, Operators cannot pick **Credit not found** in the
Outcome filter, and the CRM shows the old sample values. The payload already sends
`credit_not_found`, so a workflow with no filter starts for it, and one filtered to
other outcomes no longer starts for a Credit Not Found file.

Publishing a trigger version does not make the trigger visible to existing
installs. Module availability is a separate step (see
[The trigger was missing at first](#the-trigger-was-missing-at-first)): a new Major
app version, which each install then confirms. Both are held for Kevin's explicit
authorization. Never deprecate app version 7.0.1: it has about 287 installs, and
deprecating a version automatically uninstalls every install on it.

### Publishing the next version

Every command below except `validate` reads or writes the developer portal. None
has been run for this version; each needs explicit approval at the time.

```bash theme={null}
SCRATCH=<scratch directory outside the repo>
T=$SCRATCH/fm-app/src/modules/workflows/triggers/underwriting-completed.json

# 1. Pull the portal state (read).
ghl app pull 697a568ce94ccaee772a84b0 --directory $SCRATCH --folder fm-app --json

# 2. Clone published 1.0 into the next draft (server write; the server assigns the
#    version number and refreshes the local file). It refuses while local edits exist,
#    so run it before step 3.
ghl app triggers new-version underwriting_completed --directory $SCRATCH/fm-app --json

# 3. Copy the repo's editable fields into the draft only. The pulled version, status,
#    templateId and the `${remote}` subscription header stay as they are.
jq --slurpfile repo docs/internal/marketplace-triggers/underwriting-completed.json '
  .versions |= map(if .status == "draft"
    then . + ($repo[0].versions[0] | {info, customVarsJson, filters, customVars})
    else . end)' "$T" > "$T.next" && mv "$T.next" "$T"

# 4. Validate locally (no API call). <draft> is the version step 2 printed.
ghl app triggers validate --directory $SCRATCH/fm-app
ghl app triggers validate --directory $SCRATCH/fm-app --publishable --trigger underwriting_completed --version <draft>

# 5. Compare with the portal (read). Only the draft should change.
ghl app triggers diff --directory $SCRATCH/fm-app --json

# 6. Update the draft (server write; not visible to Operators).
ghl app triggers push --directory $SCRATCH/fm-app --json

# 7. HELD for separate approval, and only after the code that sends
#    `credit_not_found` is deployed.
ghl app triggers publish underwriting_completed --directory $SCRATCH/fm-app \
  --notes "Adds the Credit not found outcome and real sample values." --force
```

After publish, set `versions[0].version` in the repo JSON to the published
version. `status` stays `published`.

Steps 2 to 4 were rehearsed offline on 2026-10-14 against a simulated pull (1.0
published, a 1.1 draft): the merge changed only the draft, kept the `${remote}`
header and `templateId`, and the CLI's own schema validator
(`validateWorkflowTriggersManifest`) returned no errors in basic and publishable
mode. The repo JSON on its own also validates; publishable mode refuses 1.0
because it is not a draft.

The first push of a new trigger (not a new version) instead needs the bearer at
push time:
`GHL_UNDERWRITING_TRIGGER_AUTHORIZATION="Bearer $(cat <0600 token file>)" ghl app triggers push ...`.

## Known limitations

* The execute endpoint's auth and contact-attachment format are not documented. We
  send the Location's OAuth access token plus `Version`, and put the Contact ID at
  the payload root (`contactId`) and as `contact.id`. The CRM documents that a
  custom-trigger workflow may run without an attached contact, but the live test
  showed the Contact attached, so the operator guide offers **Find Contact** by
  Contact ID only as a fallback.
* On a file with a stored engine workflow, a revise does not change the outcome,
  because the stored workflow wins. A file revised from Qualified to Doesn't Qualify
  still sends outcome `qualified`, and notifications use the same key, while its
  decision and status fields show the revision. This predates the trigger and is
  unchanged here; whether to project overrides on those files too is pending
  Kevin's decision. Credit Not Found files are the exception (see
  [What it is](#what-it-is)).
* On a revised Credit Not Found file, `fundingPaths`, `qualifyingProducts` and the
  overlay flags stay empty, because they read the stored workflow and the engine
  stored none.
* Other null-workflow rows (an actionable product with none able to proceed) and
  older Credit Not Found rows stored without `cardStatusCode` still derive
  `qualified`.
* Only the `is-any-of` shape and `filters: []` have been observed (see
  [Filters](#filters)). Every other spelling, including any negation, is
  hypothetical. An unrecognized shape stops that workflow from starting until we
  support it.
* Results that existed before a workflow was published are not replayed. State is
  per Location and Contact, so a new workflow on a Location that already has one
  waits for that Contact's result to change.
* There is no synthetic `triggers test` command. End-to-end testing needs a real
  workflow subscription and its server-issued `targetUrl`.
* Ordering is best effort. The generation check runs just before each POST, so
  a newer result queued between that check and the POST, or one sent by another
  `deliver` at the same time, can still reach the CRM first. Workflows that act
  on the result should read the current values (they are on every run) rather
  than assume runs arrive in order.
* Writes outside the hooked handlers (for example processor-side edits in other
  modules) do not fire the trigger until the next hooked write.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.