Underwriting Completed marketplace workflow trigger
Internal runbook. Operator-facing steps live indocs/operators/getting-started/underwriting-completed-trigger.mdx.
What it is
A marketplace workflow trigger, keyunderwriting_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).
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}thatderiveWorkflowFromProductsstores for them, using the sameunderwritingStatuspredicates, 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 storedproductsverdicts are the effective ones: any MCA, SBA or BLOC product atqualifiedorqualified_conditionaladdsestablished_fundingafter the term loan and card paths, with no overlay. Rows without storedproductshave 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 stayscredit_not_foundand a card in manual review ismanual_review, whatever the term loan or Revenue Based verdicts say. With no qualifying path, a term loan in manual review ismanual_review, and anything else isdeclined_no_fit(these rows carry no repair-referral signal). Both senders projectproducts(narrowed to{product, status}) so the send-time outcome matches the queued one.tests/automationOutcome.test.tschecks every card × term loan × Revenue Based cell againstderiveWorkflowFromProducts.
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
- Subscribe. When an Operator saves a workflow that uses the trigger, the CRM
POSTs to
POST /api/marketplace/underwriting-triggerwithtriggerData.{id,key,filters,eventType,targetUrl}andextras.{locationId,workflowId,companyId}. CREATED/UPDATED upsert aworkflowTriggerSubscriptionsrow keyed bytriggerId; DELETED removes it. AtriggerIdnever moves between Locations. Onlyhttps://services.leadconnectorhq.com/workflows-marketplace/triggers/execute/...is accepted as atargetUrl, because we send the Location’s access token there. - 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 coversconfirmFundingPlan) and target utilization.queuereturns 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 (minuscompletedAt) withunderwritingTriggerStatesand only schedulesdeliverwhen something changed. That keeps the one-click-many-writes paths (confirmFundingPlan) and no-op re-runs from billing Operators for duplicate premium executions. - Fan out.
fanoutpages through the Location’s subscriptions 50 at a time and schedules onedeliverper 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 for the shapes it reads. - Deliver. Before every POST, including an inline retry and the resend
after a token refresh,
deliverre-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 newergenerationby then (logged as"dropped"). Otherwise it POSTs to the subscription’s currenttargetUrl, 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 carriesAuthorization: Bearer <location access token>andVersion: 2021-07-28. A 401 refreshes the token once; a 404/410 removes the subscription unless it was re-saved with a differenttargetUrlafter the POST. - 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):
- filtered to Qualified:
{"field":"underwriting.outcome","operator":"is-any-of","value":["qualified"]} - no filter:
filters: []
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.outcomeoroutcome. Any other field is a"field"failure, even with an empty value.contains,does_not_containand the like mean Outcome membership; nothing is compared as a scalar. - Operator: spelling and case are normalized (
is-any-of,isAnyOfandIS ANY OFare allis_any_of), then looked up in two explicit lists inconvex/lib/underwritingWorkflowTrigger.ts, with no wildcards. The positive list includes empty,=,eq,equals,is,in,is_any_of,any_of,one_ofandcontains. The negated list includes!=,<>,ne,nin,not_in,is_not,is_not_any_of,none_of,does_not_containandexcludes, and inverts the match. Any other spelling, such asnot_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 labelQualified) is a"value"failure.
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:
workflowTriggerSubscriptions.filters to see the new shape before adding
support for it.
Live test (2026-10-14)
LocationoE9ILHco0XXZUu79wAA0, against deployed main:
- Two workflows:
a568cd3e-0fa1-458b-b79d-3e450fd473b1(no filter) and8c1750d3-cf2a-4330-802e-89353f66f623(Outcome filtered to Qualified). Both are back in Draft. - Saving them sent subscription POSTs that returned 200. Prod has both
workflowTriggerSubscriptionsrows, scoped to the Location, with the observed filter shapes. - A notes-only revise on a test Contact’s Credit Not Found file was delivered: 201
at about 20:51:07Z, with
outcome: "qualified"andreviewStatus: "pending". That outcome was the baseline bug the Credit Not Found projection fixes; the stored row hadunderwritingDecision: "Credit Not Found",cardStatusCode: "credit_not_found",workflow: nulland no decision overrides, before and after the revise.tests/underwritingWorkflowTrigger.test.tsreplays 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) 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
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 withopenssl rand -hex 32into a 0600 file outside the repo. Set it on Vercel (Production, Preview, Development; Sensitive) before the trigger is pushed, then redeploy. The pushed header isBearer <token>fromGHL_UNDERWRITING_TRIGGER_AUTHORIZATIONat 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 asnew-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): 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 exceptvalidate reads or writes the developer portal. None
has been run for this version; each needs explicit approval at the time.
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 ascontact.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). - On a revised Credit Not Found file,
fundingPaths,qualifyingProductsand 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
cardStatusCodestill derivequalified. - Only the
is-any-ofshape andfilters: []have been observed (see 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 testcommand. End-to-end testing needs a real workflow subscription and its server-issuedtargetUrl. - 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
deliverat 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.

