ShowMojo → GoHighLevel Middleware

Glue service that turns ShowMojo lead webhooks into enriched GoHighLevel contacts.

v1.0.0Updated Jul 11, 2026, 11:33 AM UTC

ShowMojo → GoHighLevel Middleware ("aimylistings")

Directory / Wiki page. This single page explains what this program is, how it works, how to operate it day-to-day (with step-by-step SOPs), and how to safely expand it. It is written so a brand-new employee can read it top-to-bottom and understand the whole thing — and so it can be handed to an AI assistant as a system prompt to keep building on the project.

If you only read one paragraph, read "The 30-second version" right below.


The 30-second version

When someone asks to see a rental home on ShowMojo (the self-showing / scheduling tool), ShowMojo sends us a "a new lead came in" message (a webhook). This program catches that message, figures out which community, city, and address the lead belongs to, figures out which team member should own it, and then creates or updates that person as a contact in GoHighLevel (our CRM, also called LeadConnector) with the right tags and custom fields. From there, GoHighLevel's automations take over (texts, calls, calendars, pipelines).

It is the glue between ShowMojo and GoHighLevel. It has no database of its own that it depends on, no UI to log into, and it runs automatically 24/7. The golden rule baked into the code: a lead is never dropped — if any lookup fails, it falls back to a safe default and still delivers the contact.

   A new lead comes in
   (renter requests a home — not a confirmed booking)
            │
            ▼
   ShowMojo  ──webhook──▶  THIS PROGRAM  ──upsert──▶  GoHighLevel (CRM)
                          (enrich + route)            (automations fire)

Glossary (read this first if any term is new)

TermPlain-English meaning
ShowMojoThe tool renters use to schedule/self-show homes. It is the source of leads.
GoHighLevel (GHL) / LeadConnectorOur CRM. The destination. Holds contacts, runs the texts/calls/pipelines.
WebhookAn automatic HTTP message one system sends another when something happens ("a lead was created"). We receive these from ShowMojo.
LeadA person who wants to see a home (name + email/phone + which listing).
ListingA specific home/unit being shown. Identified by a listing_uid.
listing_uidShowMojo's unique ID for a listing. The key we use to look everything up.
CommunityThe named property/neighborhood a listing belongs to (e.g. "Amos Valley").
MarketThe city + state for the listing (e.g. "Springfield, IL").
EnrichmentAdding the community/market/address/owner info to a bare lead before sending it on.
Upsert"Update or insert" — create the contact if new, update it if it already exists (matched by email/phone).
TagA label on a GHL contact (e.g. showmojo, Little_Rock). Drives automations. Tags auto-create in GHL.
Custom fieldA named data slot on a GHL contact (e.g. community_name). These must be pre-created in GHL or the values are silently dropped.
SupabaseAn optional database. Only used to override auto-derived values or attach GHL routing IDs. The program works fine without it.
VercelWhere the program is hosted/deployed.
Bearer tokenA shared secret password that proves an incoming webhook is really from us.

Why this exists (the business reason)

Before this middleware, a ShowMojo lead and our GoHighLevel CRM didn't talk to each other cleanly. Leads needed to land in GHL already labeled with the correct community and city, and assigned to the right person, so the right automation runs and the right agent follows up. Doing that by hand is slow and error-prone.

This service does it automatically, in well under a second, every time, with no human in the loop — and it is deliberately defensive: a missing listing, a flaky API, a lead with no phone number, etc. never causes a lead to be lost.


How it works (the lead's journey, step by step)

Every inbound webhook runs through lib/webhook-handler.tshandleInboundWebhook(). The numbered steps below map 1:1 to the code so you can follow along.

  1. Check the password (auth). The request must carry the source's bearer token, either as an Authorization: Bearer <token> header or a ?token=<token> query param. Compared in constant time (so attackers can't guess it character by character). No/invalid token → 401. If the server forgot to set the token env var at all → 500.
  2. Read the body. Parse the JSON ShowMojo sent. Not valid JSON → 400.
  3. Normalize the lead. Pull out the fields we care about and split the full name into first/last safely. Unrecognizable shape → 400.
  4. Is this an event we forward? ShowMojo fires events for everything. We act on new leads (action == "lead_created") and on appointment milestones, which add a GHL pipeline tag so contacts can be moved by tag automation: create / auto_create_by_smsappointment_scheduled, confirm / confirm_automaticallyappointment_confirmed, the self-show attendance events (lockbox_showing_started / self_show_code_distributed / distribute_self_show_code / confirm_secured_lockbox) → prospect_showed (the prospect showed up for a self-tour; not a hard "walked in" — a manual set_no_show can still follow), cancelappointment_canceled, set_no_showno_show. Anything else gets a 200 "skipped": true so ShowMojo marks it delivered and stops retrying.
  5. Auto-resolve the community name (ShowMojo Listings API). Using the lead's listing_uid, call GET /api/v3/listings/:uid. From the listing title we derive the community (the part before " - ", e.g. "Oakwood - 101""Oakwood") and the market (city, state). If this lookup fails, we do not stop — we degrade to the fallback.
  6. Optional Supabase override. If Supabase is configured and has an active row for this listing_uid, it overrides the community/market/ address and supplies GHL routing IDs (calendar/workflow/pipeline/stage).
  7. Assign an owner. Match ShowMojo's team_member_name to a GHL user (by full name, then unique first name, then unique last name; aliases supported). Requires the users.readonly scope on the GHL token. No match → unassigned (never blocks the lead).
  8. No email and no phone? GHL needs at least one to identify a contact. If the lead has neither, skip the upsert and return a 200 "skipped": true (not an error ShowMojo would retry).
  9. Upsert into GoHighLevel. Send the contact (name, email, phone, source, owner, tags, custom fields) to GHL's contacts/upsert. Create-or-update by email/phone.
  10. Respond with JSON describing what happened (see below).

The enrichment layering (most important concept)

Enrichment is built lowest priority first, each layer overwriting the last:

  parsed address (fallback)  →  ShowMojo Listings API (auto)  →  Supabase row (override)
        always runs                   if configured                   if a row exists
  • fallback — we parse listing_full_address ("21 Amos Valley Dr, Springfield, IL, 62702") for a best-effort city/state. community_name becomes "Unknown Community".
  • showmojo — the normal path. Community + market come from the live listing. source: "showmojo", mapped: true.
  • supabase — only when you've added an override row. source: "supabase".

The source field in the response tells you which layer won.

Tags & custom fields actually sent to GHL

  • Tags (exactly two): showmojo (every contact, marks the pipeline) + one city tag from a fixed list, spaces written as underscores so they match GHL automations exactly: Conway, Hot_Springs, Jacksonville, Little_Rock, Mena, mountain_Home, North_Little_Rock, Sherwood, Jonesboro. Any other city → other. The city is taken from the resolved market (matched case-insensitively, spaces/underscores treated the same).
  • Custom fields sent (nulls/empties are dropped): showmojo_event_id, showmojo_showing_uid, showmojo_listing_uid, showmojo_link (the public https://showmojo.com/l/<uid> page, for re-sending to the lead later), showmojo_listing_address, showmojo_listing_title, community_name, market, property_address (the listing's home address, for GHL's {{contact.property_address}} — pushed on new leads and refreshed on every appointment update except cancellations, which never overwrite it), appointment_address, lead_source, referrer_url, showmojo_team_member_name, showmojo_team_member_uid, showmojo_action, and the appointment details forwarded from the ShowMojo showing: appointment_date and appointment_time (ShowMojo's single showtime split into the CRM's separate date/time fields), appointment_confirmed (when it was confirmed), showing_notes, and ai_context.

⚠️ Important nuance (and a known gap). The code computes an enrichment.tags value — the ShowMojo - Unmapped Listing tag and any per-community tags from Supabase — but buildTags() currently sends only showmojo + the city tag. So those extra tags are not reaching GHL today. See "Good first expansions" below.

Example response

{
  "success": true,
  "mapped": true,
  "source": "showmojo",
  "showmojo_listing_uid": "0f376cd0ef",
  "community_name": "Amos Valley",
  "market": "Springfield, IL",
  "ghl_contact_id": "abc123",
  "ghl_assigned_user_id": "user_456"
}

Endpoints (the URLs this service exposes)

Base URL in production: https://showmojo.mhgbrain.com

MethodPathAuthPurpose
POST/patriot-communitiesbearer tokenThe Patriot Communities ShowMojo webhook. The main entry point.
GET/api/healthnoneReadiness probe — reports which integrations are configured (never reveals secrets).
GET/api/ghl-usersbearer tokenDiagnostic — lists GHL users (id + name) so you can verify assignment matching. Add ?fresh=1 to bypass the 60s cache.
GET/api/directoryoptional tokenLive sync feed of this very page (markdown + JSON metadata). The main hub pulls this to stay in sync. See "Live sync feed" below.

The homepage (/) is just the default Next.js placeholder — there is no dashboard. This is a backend service.


Live sync feed (how the main hub stays in sync)

This page is published as a live feed so another project (the "main hub") can mirror it automatically. The feed serves this exact markdown file, so any committed edit to the page shows up in the feed on the next deploy — no copy-paste, no manual sync.

Endpoint: GET https://showmojo.mhgbrain.com/api/directory

  • Default (JSON): returns the page plus metadata:
    {
      "id": "showmojo-ghl-middleware",
      "title": "ShowMojo → GoHighLevel Middleware",
      "summary": "Glue service that turns ShowMojo lead webhooks into enriched GoHighLevel contacts.",
      "version": "1.0.0",
      "content_hash": "sha256:<hex>",
      "generated_at": "2026-05-22T00:00:00.000Z",
      "source": {
        "repo": "benjamin1160/aimylistings",
        "path": "docs/wiki/showmojo-ghl-middleware.md",
        "service_url": "https://showmojo.mhgbrain.com/api/directory"
      },
      "format": "markdown",
      "markdown": "<the full page as markdown>"
    }
    
  • ?format=md: returns the raw page as text/markdown.
  • Change detection: the response carries an ETag. Send it back as If-None-Match and you get a cheap 304 Not Modified when nothing changed. You can also just compare content_hash.
  • Auth: public by default. If DIRECTORY_FEED_TOKEN is set in the environment, the feed requires it as Authorization: Bearer <token> or ?token=<token>.

How the hub consumes it: poll the endpoint on a schedule (e.g. every 5–15 min) or on page-load, compare content_hash to the last stored value, and when it differs, replace the stored copy with the new markdown. Nothing needs to be exposed on the hub — it only makes outbound GET requests.


Environment variables (the settings & secrets)

Set these in Vercel → Project → Settings → Environment Variables (Production

  • Preview), and in .env.local for local development. See .env.example.
VariableRequiredWhat it's for
PATRIOT_COMMUNITIES_WEBHOOK_TOKENSecret that authenticates the Patriot Communities webhook.
SHOWMOJO_API_TOKEN✅*ShowMojo v3 API token (from showmojo.com/my_settings/plugins).
SHOWMOJO_API_LOGIN✅*ShowMojo login (email) — alternative to the token.
SHOWMOJO_API_PASSWORD✅*ShowMojo password — used with the login (HTTP Basic).
GHL_API_KEYGoHighLevel / LeadConnector v2 API token.
GHL_LOCATION_IDThe target GHL sub-account (location) ID.
SHOWMOJO_API_BASE_URLOverride ShowMojo base URL (default https://showmojo.com).
GHL_API_BASE_URLOverride GHL base URL (default https://services.leadconnectorhq.com).
GHL_API_VERSIONOverride GHL API version header (default 2021-07-28).
SHOWMOJO_AGENT_ALIASESJSON map to fix ShowMojo↔GHL name mismatches for owner assignment.
SUPABASE_URLOptional. Only for overrides / GHL routing IDs.
SUPABASE_SERVICE_ROLE_KEYOptional. Supabase service role key (server-side only).

* Provide either both SHOWMOJO_API_LOGIN + SHOWMOJO_API_PASSWORD, or a single SHOWMOJO_API_TOKEN (the token wins if both are set).

Secrets are only ever read server-side and are never logged.


Standard Operating Procedures (SOPs)

Each SOP is a self-contained checklist. Do them in order; don't skip steps.

SOP 1 — Check that the service is healthy

  1. Open (or curl) https://showmojo.mhgbrain.com/api/health.
  2. You want "status": "ok". The config block should show patriot_communities_token: true, showmojo_api: true, ghl: true. (supabase may be false — that's fine, it's optional.)
  3. If status is "degraded", a required env var is missing in Vercel — compare the config block to the table above and set the missing one.

SOP 2 — Send a test lead (curl)

curl -X POST "https://showmojo.mhgbrain.com/patriot-communities?token=$PATRIOT_COMMUNITIES_WEBHOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": {
      "id": 577670521,
      "action": "lead_created",
      "team_member_name": "Aleta",
      "showing": {
        "uid": "f7351e875b",
        "name": "Marena Blue",
        "email": "marenablue30@gmail.com",
        "listing_uid": "0f376cd0ef",
        "listing_full_address": "21 Amos Valley Drive, Springfield, IL, 62702"
      }
    }
  }'
  • A 200 with "success": true and a ghl_contact_id means it worked.
  • 401 = bad/missing token. 400 = malformed JSON. 502 = GHL rejected it.

SOP 3 — Onboard a NEW community (add another webhook source)

Each community gets its own clean URL and its own token.

  1. Pick a token env var name: <COMMUNITY>_WEBHOOK_TOKEN (e.g. OAKWOOD_WEBHOOK_TOKEN). Generate a long random value and add it in Vercel (Production + Preview).
  2. Create the route file: app/<community>/route.ts (e.g. app/oakwood/route.ts). Copy app/patriot-communities/route.ts and change the two strings:
    import { handleInboundWebhook } from "@/lib/webhook-handler";
    export const runtime = "nodejs";
    export const dynamic = "force-dynamic";
    export async function POST(request: Request): Promise<Response> {
      return handleInboundWebhook(request, {
        tokenEnvVar: "OAKWOOD_WEBHOOK_TOKEN",
        label: "oakwood",
      });
    }
    
  3. Deploy (push to GitHub; Vercel auto-builds).
  4. Point ShowMojo at https://showmojo.mhgbrain.com/oakwood?token=<OAKWOOD_WEBHOOK_TOKEN> (see SOP 6).
  5. Test with SOP 2 against the new path.

That's it — no other code changes are required for a new source.

SOP 4 — Create the GoHighLevel custom fields (one-time per GHL account)

Tags auto-create, custom fields do not. If you skip this, custom-field values are silently dropped on upsert.

  1. In GHL: Settings → Custom Fields → Add Field (Contact).
  2. Create one field per key, with the field key matching exactly: showmojo_event_id, showmojo_showing_uid, showmojo_listing_uid, showmojo_link, showmojo_listing_address, showmojo_listing_title, community_name, market, property_address, appointment_address, lead_source, referrer_url, showmojo_team_member_name, showmojo_team_member_uid, showmojo_action, appointment_date, appointment_time, appointment_confirmed, showing_notes, ai_context.
  3. Send a test lead (SOP 2) and confirm the values land on the contact.

SOP 5 — Configure the ShowMojo webhook

The endpoint accepts the token two ways, so it works regardless of whether ShowMojo lets you set headers.

  • Option A (recommended — URL token): set the ShowMojo webhook destination to https://showmojo.mhgbrain.com/patriot-communities?token=<PATRIOT_COMMUNITIES_WEBHOOK_TOKEN>
  • Option B (header): use the bare URL plus header Authorization: Bearer <PATRIOT_COMMUNITIES_WEBHOOK_TOKEN>

Note: with Option A the token appears in the URL (and may show in logs). That's an accepted trade-off; treat the token as rotatable (see SOP 8).

SOP 6 — Verify / fix lead owner assignment

  1. Hit the diagnostic: GET /api/ghl-users?token=<token>&fresh=1. This lists GHL user ids and exact names.
  2. Compare those names to the team_member_name ShowMojo sends.
  3. If a name doesn't line up (e.g. ShowMojo "DJ Mata" vs GHL "Daniel Mata"), set the alias env var in Vercel:
    SHOWMOJO_AGENT_ALIASES={"DJ Mata":"Daniel Mata"}
    
    (You can also map directly to a GHL user id: {"DJ Mata":"<ghlUserId>"}.)
  4. Make sure the GHL token has the users.readonly scope, or assignment is skipped entirely (contact lands unassigned, lead still delivered).

SOP 7 — (Optional) Override a listing or attach GHL routing IDs (Supabase)

Only needed when you want to override the auto-derived community/market or route to a specific calendar/workflow/pipeline/stage.

  1. One-time: create the showmojo_listings table (SQL is in the project README.md).
  2. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in Vercel.
  3. Insert one row per listing, keyed on showmojo_listing_uid (= the webhook's showing.listing_uid):
    insert into public.showmojo_listings (
      showmojo_listing_uid, community_name, market, state, appointment_address,
      ghl_calendar_id, ghl_workflow_id, ghl_pipeline_id, ghl_stage_id, tags, ai_context
    ) values (
      '0f376cd0ef', 'Amos Valley', 'Springfield, IL', 'IL',
      '21 Amos Valley Drive, Springfield, IL, 62702',
      'cal_123','wf_123','pipe_123','stage_123',
      array['VIP'], 'Pet-friendly, office open 9-5.'
    );
    
  4. To disable a mapping without deleting it: set active = false.

SOP 7.5 — Fire an AI message when an appointment is scheduled (Assistable.ai)

You want the AI to text the prospect automatically the instant a showing is booked. This runs in the middleware (not a GHL workflow), because the Assistable text endpoint needs the contact's conversation_id, which only our server can look up. On a booked showing (create / auto_create_by_sms) the handler, right after the GHL upsert: (1) finds/creates the contact's GHL conversation, then (2) calls Assistable to generate + send the message. It is fully non-blocking — the lead is already in GHL, so a failure here is logged and ignored.

It is inert until configured. No contact is messaged unless both the token and assistant id are set. To enable (Patriot shown; THW uses THW_-prefixed):

ASSISTABLE_API_TOKEN=ask_live_...      # Assistable API key
ASSISTABLE_ASSISTANT_ID=...            # the assistant that sends the message
# ASSISTABLE_SUBACCOUNT_ID=...         # optional; defaults to your GHL location id
# ASSISTABLE_API_BASE_URL=https://api.assistable.ai

Endpoint reality (verified live): the documented "GHL-Safe" route POST /v2/ghl-chat-completion returns 404 on the live API. The deployed text endpoint is POST /v3/chat/completions, which lib/assistable.ts targets.

⚠️ v2 vs v3 agent gap. v3 resolves assistants by a v3 subaccount_id, which is not always your GHL location id. An agent built in Assistable's older v2/workspace model (the one the v2 /v2/ghl/make-call voice route resolves by location_id) may not be visible to v3 yet — you'll see "Assistant not found for this subaccount". Fix: ask Assistable for the v3 subaccount id (and confirm the agent exists in v3), then set ASSISTABLE_SUBACCOUNT_ID. Until then the call can't send (the rest of the pipeline is unaffected).

Verify: add the appointment_scheduled tag isn't enough on its own — the AI fires on the ShowMojo booking event. To smoke-test the chain, send a create event (SOP 2 with "action":"create") for a contact that has a phone, then watch the Vercel logs for Assistable message generated / returned empty / failed.

SOP 8 — Rotate a leaked/old webhook token

  1. Generate a new random value for the token (e.g. PATRIOT_COMMUNITIES_WEBHOOK_TOKEN).
  2. Update it in Vercel (Production + Preview) and redeploy.
  3. Update the ShowMojo webhook URL/header with the new value (SOP 5).
  4. Test (SOP 2). The old token now returns 401.

SOP 9 — Deploy a change

  1. Commit and push to GitHub (open a PR if that's your team's flow).
  2. Vercel auto-builds; framework auto-detects as Next.js — no extra build config.
  3. After deploy, run SOP 1 (health) and SOP 2 (test lead) as a smoke test.

SOP 10 — Run it locally

npm install
cp .env.example .env.local   # then fill in values
npm run dev
# POST to http://localhost:3000/patriot-communities

Troubleshooting (symptom → likely cause → fix)

SymptomLikely causeFix
Webhook returns 401Wrong/missing tokenCheck the ?token= / header matches the Vercel env var (SOP 5).
Webhook returns 400Body isn't valid ShowMojo JSONConfirm ShowMojo is sending the event.showing shape.
Webhook returns 500Token env var not set on serverSet the *_WEBHOOK_TOKEN in Vercel (SOP 1).
Webhook returns 502GHL rejected the upsertCheck GHL_API_KEY / GHL_LOCATION_ID; read Vercel logs.
200 but "skipped": trueEvent wasn't a forwarded action (new lead / appointment), or had no email/phoneExpected behavior — not an error.
Contact created but community = "Unknown Community"ShowMojo Listings lookup failed/unmappedTidy the listing title in ShowMojo, or add a Supabase override (SOP 7).
Custom fields blank in GHLFields not pre-created in GHLCreate them (SOP 4).
Contact unassignedName mismatch or missing scopeAdd an alias / grant users.readonly (SOP 6).
Wrong city tag / otherCity not in the fixed list, or market resolved oddlyConfirm market resolves to a listed city; otherwise expected.

Where to look: Vercel → Project → Logs. Each log line is prefixed with the source label (e.g. [patriot-communities]). Secrets are never logged.


How to expand on it (developer / AI guide)

Repository map

app/
  patriot-communities/route.ts   ← inbound webhook (entry point); copy to add a source
  api/health/route.ts            ← readiness probe
  api/ghl-users/route.ts         ← diagnostic: list GHL users (token-protected)
  page.tsx, layout.tsx           ← default Next.js placeholder UI (not used)
lib/
  webhook-handler.ts             ← THE ORCHESTRATOR. Auth → parse → enrich → assign → upsert
  showmojo.ts                    ← validate payload + normalize the lead (split name)
  showmojo-api.ts                ← ShowMojo v3 Listings API client (auto name resolution, cached)
  ghl.ts                         ← GoHighLevel client: contacts/upsert + user assignment
  supabase.ts                    ← optional override lookups (showmojo_listings table)
  parse-address.ts               ← best-effort fallback address parser
types/
  showmojo.ts                    ← webhook payload + Listings API + NormalizedLead types
  listing.ts                     ← Supabase row + ListingEnrichment types

The mental model: a thin route file delegates to handleInboundWebhook, which calls small, single-purpose lib/* modules. To change behavior, you almost always edit lib/webhook-handler.ts and/or one lib/* client — not the routes.

Conventions & invariants — DO NOT BREAK THESE

  1. Never drop a lead. Every external lookup (ShowMojo API, Supabase, GHL users) is wrapped so a failure degrades gracefully instead of erroring. Preserve this when adding logic.
  2. Acknowledge non-actionable events with 200. Skips (unhandled action, no email/phone) return 200 "skipped": true so ShowMojo doesn't retry. Only real delivery failures return 5xx.
  3. Constant-time token compare. Auth uses timingSafeEqual. Don't replace it with ===.
  4. Never log secrets. Tokens/keys are server-side only.
  5. Each source = one route file + one token env var. Keep this pattern; don't multiplex sources behind one token.
  6. Custom fields must exist in GHL to persist; tags auto-create.
  7. This is NOT the Next.js you know. Per AGENTS.md, this Next.js version (16.x) has breaking changes vs older training data. Before writing route / framework code, read the relevant guide in node_modules/next/dist/docs/.

Good first expansions (ranked)

  1. Send the computed enrichment.tags to GHL. Today buildTags() returns only [showmojo, cityTag], so the ShowMojo - Unmapped Listing tag and any per-community Supabase tags are computed but never sent. Wiring them in (deduped) is a small, high-value fix. See lib/webhook-handler.ts (buildTags and buildEnrichment).
  2. Use the Supabase GHL routing IDs. ghl_calendar_id / workflow / pipeline / stage are read into the enrichment but not yet sent in the upsert. Add them to the GHL payload when present.
  3. Onboard more communities (SOP 3) — pure config/route work.
  4. Add per-source overrides (e.g. a different default tag per community).

Local checks before you push

npm run lint     # eslint
npm run build    # next build (catches type/route errors)

There is no automated test suite today; verify changes with SOP 2 (curl) plus a health check (SOP 1).


Use as an AI system prompt

Copy everything between the lines below as the system prompt for an AI agent tasked with maintaining or extending this project.


You are an engineering assistant working on "aimylistings", a Next.js (App Router, Next 16) middleware that turns ShowMojo lead webhooks into enriched GoHighLevel (LeadConnector) contacts. It runs on Vercel at https://showmojo.mhgbrain.com. There is no UI and no required database; it is a defensive, stateless backend service.

What it does: receives a ShowMojo lead_created webhook → resolves the community/market from the ShowMojo Listings API (with an optional Supabase override and an address-parse fallback) → assigns a GHL owner by matching the ShowMojo team member → upserts the contact into GHL with a showmojo tag, one city tag, and custom fields.

Architecture: thin route files in app/<source>/route.ts delegate to lib/webhook-handler.ts (handleInboundWebhook), which calls focused clients: lib/showmojo.ts (parse/normalize), lib/showmojo-api.ts (Listings API), lib/supabase.ts (optional overrides), lib/ghl.ts (upsert + user assignment), lib/parse-address.ts (fallback). Types live in types/.

Hard rules you must preserve:

  1. Never drop a lead — wrap every external call so failures degrade to the next layer / unassigned, never a 5xx, unless GHL delivery itself fails.
  2. Return 200 {"skipped": true} for unhandled actions and for leads with neither email nor phone — never 5xx (ShowMojo would retry).
  3. Authenticate every inbound source with a per-source bearer token (header or ?token=) using constant-time comparison.
  4. Never log or expose secrets.
  5. One inbound source = one app/<source>/route.ts + one <SOURCE>_WEBHOOK_TOKEN.
  6. Tags auto-create in GHL; custom fields must be pre-created to persist.
  7. This is not the Next.js in your training data. Read node_modules/next/dist/docs/ before writing framework/route code.

When asked to add a community: create app/<name>/route.ts (copy patriot-communities), add <NAME>_WEBHOOK_TOKEN, deploy, point ShowMojo at /<name>?token=....

Before finishing: run npm run lint and npm run build; there is no test suite, so validate logic against the flow in this document.


Reference links

  • Project README.md — deeper setup, Supabase SQL schema, deploy notes.
  • .env.example — every environment variable with inline notes.
  • AGENTS.md — the "this is not the Next.js you know" rule for AI/dev work. </content>
</invoke>