ShowMojo → GoHighLevel Middleware
Glue service that turns ShowMojo lead webhooks into enriched GoHighLevel contacts.
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)
| Term | Plain-English meaning |
|---|---|
| ShowMojo | The tool renters use to schedule/self-show homes. It is the source of leads. |
| GoHighLevel (GHL) / LeadConnector | Our CRM. The destination. Holds contacts, runs the texts/calls/pipelines. |
| Webhook | An automatic HTTP message one system sends another when something happens ("a lead was created"). We receive these from ShowMojo. |
| Lead | A person who wants to see a home (name + email/phone + which listing). |
| Listing | A specific home/unit being shown. Identified by a listing_uid. |
listing_uid | ShowMojo's unique ID for a listing. The key we use to look everything up. |
| Community | The named property/neighborhood a listing belongs to (e.g. "Amos Valley"). |
| Market | The city + state for the listing (e.g. "Springfield, IL"). |
| Enrichment | Adding 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). |
| Tag | A label on a GHL contact (e.g. showmojo, Little_Rock). Drives automations. Tags auto-create in GHL. |
| Custom field | A named data slot on a GHL contact (e.g. community_name). These must be pre-created in GHL or the values are silently dropped. |
| Supabase | An optional database. Only used to override auto-derived values or attach GHL routing IDs. The program works fine without it. |
| Vercel | Where the program is hosted/deployed. |
| Bearer token | A 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.ts →
handleInboundWebhook(). The numbered steps below map 1:1 to the code so you
can follow along.
- 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. - Read the body. Parse the JSON ShowMojo sent. Not valid JSON → 400.
- Normalize the lead. Pull out the fields we care about and split the full name into first/last safely. Unrecognizable shape → 400.
- 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_sms→appointment_scheduled,confirm/confirm_automatically→appointment_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 manualset_no_showcan still follow),cancel→appointment_canceled,set_no_show→no_show. Anything else gets a 200"skipped": trueso ShowMojo marks it delivered and stops retrying. - Auto-resolve the community name (ShowMojo Listings API). Using the
lead's
listing_uid, callGET /api/v3/listings/:uid. From the listingtitlewe 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. - Optional Supabase override. If Supabase is configured and has an
activerow for thislisting_uid, it overrides the community/market/ address and supplies GHL routing IDs (calendar/workflow/pipeline/stage). - Assign an owner. Match ShowMojo's
team_member_nameto a GHL user (by full name, then unique first name, then unique last name; aliases supported). Requires theusers.readonlyscope on the GHL token. No match → unassigned (never blocks the lead). - 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). - 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. - 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_namebecomes"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 resolvedmarket(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 publichttps://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_dateandappointment_time(ShowMojo's singleshowtimesplit into the CRM's separate date/time fields),appointment_confirmed(when it was confirmed),showing_notes, andai_context.
⚠️ Important nuance (and a known gap). The code computes an
enrichment.tagsvalue — theShowMojo - Unmapped Listingtag and any per-communitytagsfrom Supabase — butbuildTags()currently sends onlyshowmojo+ 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
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /patriot-communities | bearer token | The Patriot Communities ShowMojo webhook. The main entry point. |
GET | /api/health | none | Readiness probe — reports which integrations are configured (never reveals secrets). |
GET | /api/ghl-users | bearer token | Diagnostic — lists GHL users (id + name) so you can verify assignment matching. Add ?fresh=1 to bypass the 60s cache. |
GET | /api/directory | optional token | Live 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 astext/markdown.- Change detection: the response carries an
ETag. Send it back asIf-None-Matchand you get a cheap304 Not Modifiedwhen nothing changed. You can also just comparecontent_hash. - Auth: public by default. If
DIRECTORY_FEED_TOKENis set in the environment, the feed requires it asAuthorization: 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.localfor local development. See.env.example.
| Variable | Required | What it's for |
|---|---|---|
PATRIOT_COMMUNITIES_WEBHOOK_TOKEN | ✅ | Secret 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_KEY | ✅ | GoHighLevel / LeadConnector v2 API token. |
GHL_LOCATION_ID | ✅ | The target GHL sub-account (location) ID. |
SHOWMOJO_API_BASE_URL | ⬜ | Override ShowMojo base URL (default https://showmojo.com). |
GHL_API_BASE_URL | ⬜ | Override GHL base URL (default https://services.leadconnectorhq.com). |
GHL_API_VERSION | ⬜ | Override GHL API version header (default 2021-07-28). |
SHOWMOJO_AGENT_ALIASES | ⬜ | JSON map to fix ShowMojo↔GHL name mismatches for owner assignment. |
SUPABASE_URL | ⬜ | Optional. Only for overrides / GHL routing IDs. |
SUPABASE_SERVICE_ROLE_KEY | ⬜ | Optional. 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
- Open (or
curl)https://showmojo.mhgbrain.com/api/health. - You want
"status": "ok". Theconfigblock should showpatriot_communities_token: true,showmojo_api: true,ghl: true. (supabasemay befalse— that's fine, it's optional.) - If
statusis"degraded", a required env var is missing in Vercel — compare theconfigblock 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
200with"success": trueand aghl_contact_idmeans 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.
- 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). - Create the route file:
app/<community>/route.ts(e.g.app/oakwood/route.ts). Copyapp/patriot-communities/route.tsand 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", }); } - Deploy (push to GitHub; Vercel auto-builds).
- Point ShowMojo at
https://showmojo.mhgbrain.com/oakwood?token=<OAKWOOD_WEBHOOK_TOKEN>(see SOP 6). - 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.
- In GHL: Settings → Custom Fields → Add Field (Contact).
- 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. - 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
- Hit the diagnostic:
GET /api/ghl-users?token=<token>&fresh=1. This lists GHL user ids and exact names. - Compare those names to the
team_member_nameShowMojo sends. - If a name doesn't line up (e.g. ShowMojo "DJ Mata" vs GHL "Daniel Mata"),
set the alias env var in Vercel:
(You can also map directly to a GHL user id:SHOWMOJO_AGENT_ALIASES={"DJ Mata":"Daniel Mata"}{"DJ Mata":"<ghlUserId>"}.) - Make sure the GHL token has the
users.readonlyscope, 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.
- One-time: create the
showmojo_listingstable (SQL is in the projectREADME.md). - Set
SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYin Vercel. - Insert one row per listing, keyed on
showmojo_listing_uid(= the webhook'sshowing.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.' ); - 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-callvoice route resolves bylocation_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 setASSISTABLE_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
- Generate a new random value for the token (e.g.
PATRIOT_COMMUNITIES_WEBHOOK_TOKEN). - Update it in Vercel (Production + Preview) and redeploy.
- Update the ShowMojo webhook URL/header with the new value (SOP 5).
- Test (SOP 2). The old token now returns
401.
SOP 9 — Deploy a change
- Commit and push to GitHub (open a PR if that's your team's flow).
- Vercel auto-builds; framework auto-detects as Next.js — no extra build config.
- 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)
| Symptom | Likely cause | Fix |
|---|---|---|
Webhook returns 401 | Wrong/missing token | Check the ?token= / header matches the Vercel env var (SOP 5). |
Webhook returns 400 | Body isn't valid ShowMojo JSON | Confirm ShowMojo is sending the event.showing shape. |
Webhook returns 500 | Token env var not set on server | Set the *_WEBHOOK_TOKEN in Vercel (SOP 1). |
Webhook returns 502 | GHL rejected the upsert | Check GHL_API_KEY / GHL_LOCATION_ID; read Vercel logs. |
200 but "skipped": true | Event wasn't a forwarded action (new lead / appointment), or had no email/phone | Expected behavior — not an error. |
| Contact created but community = "Unknown Community" | ShowMojo Listings lookup failed/unmapped | Tidy the listing title in ShowMojo, or add a Supabase override (SOP 7). |
| Custom fields blank in GHL | Fields not pre-created in GHL | Create them (SOP 4). |
| Contact unassigned | Name mismatch or missing scope | Add an alias / grant users.readonly (SOP 6). |
Wrong city tag / other | City not in the fixed list, or market resolved oddly | Confirm 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
- 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.
- Acknowledge non-actionable events with
200. Skips (unhandled action, no email/phone) return200 "skipped": trueso ShowMojo doesn't retry. Only real delivery failures return5xx. - Constant-time token compare. Auth uses
timingSafeEqual. Don't replace it with===. - Never log secrets. Tokens/keys are server-side only.
- Each source = one route file + one token env var. Keep this pattern; don't multiplex sources behind one token.
- Custom fields must exist in GHL to persist; tags auto-create.
- 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 innode_modules/next/dist/docs/.
Good first expansions (ranked)
- Send the computed
enrichment.tagsto GHL. TodaybuildTags()returns only[showmojo, cityTag], so theShowMojo - Unmapped Listingtag and any per-community Supabasetagsare computed but never sent. Wiring them in (deduped) is a small, high-value fix. Seelib/webhook-handler.ts(buildTagsandbuildEnrichment). - Use the Supabase GHL routing IDs.
ghl_calendar_id/workflow/pipeline/stageare read into the enrichment but not yet sent in the upsert. Add them to the GHL payload when present. - Onboard more communities (SOP 3) — pure config/route work.
- 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:
- Never drop a lead — wrap every external call so failures degrade to the next
layer / unassigned, never a
5xx, unless GHL delivery itself fails. - Return
200 {"skipped": true}for unhandled actions and for leads with neither email nor phone — never5xx(ShowMojo would retry). - Authenticate every inbound source with a per-source bearer token (header or
?token=) using constant-time comparison. - Never log or expose secrets.
- One inbound source = one
app/<source>/route.ts+ one<SOURCE>_WEBHOOK_TOKEN. - Tags auto-create in GHL; custom fields must be pre-created to persist.
- 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>