Skip to main content

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). 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 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):
  • 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:
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.
  • 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) 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

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): 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.
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).
  • 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). 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.