@elitedcs/ghl-mcp 3.65.3 → 3.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.66.0 — webhooks work now
4
+
5
+ The five webhook tools have never worked for anyone. They called an address
6
+ GoHighLevel does not have, so every call returned "not found" no matter what you
7
+ asked for. They are rebuilt, and this time each step was proven against a real
8
+ account before it was written down.
9
+
10
+ GoHighLevel does not offer a way to manage webhooks directly. What it does offer
11
+ is a workflow step that sends data out, so that is what these tools now build.
12
+ A webhook is a small workflow in the client's own account, which turns out to be
13
+ better than the thing that was missing:
14
+
15
+ - **It can fire on almost anything**, not just a new contact. A tag added, a
16
+ pipeline stage changed, an appointment no showed, a call missed.
17
+ - **The client can see it.** It sits in their Automation list where they can read
18
+ it, pause it, and edit it, instead of being invisible plumbing.
19
+ - **You can add your own fields and your own headers**, and merge fields fill in,
20
+ so the contact's first name arrives already filled. All of that works on every
21
+ plan.
22
+
23
+ **Two things worth knowing before you point one at production**, both measured
24
+ live rather than assumed:
25
+
26
+ - **`tags` arrives as one line of comma separated text, not a list.** Code that
27
+ checks whether it contains "vip" will also match a contact tagged
28
+ "vip-waitlist". This is in every one of the tool descriptions now.
29
+ - **If the receiving app is down, GoHighLevel keeps retrying**, about five
30
+ minutes later and then about ten. Nothing is thrown away. But the contact waits
31
+ at that step, and so does every step after it. If the send is not critical, put
32
+ it at the end of a workflow or give it a workflow of its own.
33
+
34
+ Deleting a webhook refuses to run if that workflow also does other things, so
35
+ "delete the webhook" can never quietly take a client's nurture sequence with it.
36
+
37
+ New guide: **Send GHL data to another app.** Guide coverage is now 39 of 50.
38
+
3
39
  ## 3.65.3 — the guide now covers most of what you are paying for
4
40
 
5
41
  The library had guides for 20 of the 50 areas the product touches. Six shipped
package/dist/index.js CHANGED
@@ -2854,7 +2854,7 @@ var require_package = __commonJS({
2854
2854
  "package.json"(exports2, module2) {
2855
2855
  module2.exports = {
2856
2856
  name: "@elitedcs/ghl-mcp",
2857
- version: "3.65.3",
2857
+ version: "3.66.0",
2858
2858
  mcpName: "io.github.drjerryrelth/ghl-command",
2859
2859
  description: "GoHighLevel MCP Server for Claude. 238 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
2860
2860
  main: "dist/index.js",
@@ -5757,6 +5757,41 @@ function validateActionChain(actions, existingIds) {
5757
5757
  case "remove_from_workflow":
5758
5758
  if (!attr.workflowId || !Array.isArray(attr.workflow_id)) throw new Error(`Remove from workflow action "${action.name}" needs BOTH 'workflowId' string AND 'workflow_id' array.`);
5759
5759
  break;
5760
+ case "webhook": {
5761
+ if (typeof attr.url !== "string" || !attr.url.trim()) {
5762
+ throw new Error(`Webhook action "${action.name}" needs a 'url' in attributes.`);
5763
+ }
5764
+ if (!/^https?:\/\//i.test(attr.url.trim())) {
5765
+ throw new Error(
5766
+ `Webhook action "${action.name}" has a url that does not start with http:// or https:// ("${attr.url}"). GHL saves it and then never fires it.`
5767
+ );
5768
+ }
5769
+ if (typeof attr.method !== "string" || !attr.method.trim()) {
5770
+ throw new Error(`Webhook action "${action.name}" needs a 'method' in attributes (POST, GET or PUT).`);
5771
+ }
5772
+ if (!["POST", "GET", "PUT"].includes(attr.method.trim().toUpperCase())) {
5773
+ throw new Error(
5774
+ `Webhook action "${action.name}" has method "${attr.method}". GHL's builder only offers POST, GET and PUT.`
5775
+ );
5776
+ }
5777
+ for (const field of ["customData", "headers"]) {
5778
+ const entries = attr[field];
5779
+ if (entries === void 0) continue;
5780
+ if (!Array.isArray(entries)) {
5781
+ throw new Error(`Webhook action "${action.name}" needs '${field}' to be an array of {key, value} pairs.`);
5782
+ }
5783
+ for (const entry of entries) {
5784
+ const key = isRecord(entry) ? entry.key : void 0;
5785
+ const value = isRecord(entry) ? entry.value : void 0;
5786
+ if (typeof key !== "string" || !key.trim() || value === void 0 || value === null) {
5787
+ throw new Error(
5788
+ `Webhook action "${action.name}" has an empty '${field}' pair. Every entry needs a non-empty key and a value.`
5789
+ );
5790
+ }
5791
+ }
5792
+ }
5793
+ break;
5794
+ }
5760
5795
  }
5761
5796
  }
5762
5797
  for (const action of actions) {
@@ -10041,93 +10076,300 @@ function registerCouponTools(server2, client) {
10041
10076
 
10042
10077
  // src/tools/webhooks.ts
10043
10078
  var import_zod34 = require("zod");
10044
- function registerWebhookTools(server2, client) {
10045
- safeTool(
10046
- server2,
10079
+ var WEBHOOK_PAYLOAD_NOTE = "PAYLOAD GHL SENDS (captured live, not from docs): contact_id, first_name, last_name, full_name, email, phone, tags, country, date_created, contact_source, full_address, contact_type, plus a `location` object, a `workflow` object ({id, name}), `triggerData`, and your `customData`. Sent as application/json. WATCH OUT: `tags` arrives as a COMMA-SEPARATED STRING (\"vip,newsletter\"), NOT an array \u2014 splitting is on you, and a substring check like tags.includes('vip') matches 'vip-waitlist' too. `triggerData` was empty for a contact_tag trigger.";
10080
+ var WEBHOOK_FAILURE_NOTE = "IF YOUR ENDPOINT IS DOWN (measured live against an HTTP 500, with a control run on the same workflow; GHL documents none of this): GHL does NOT silently drop the call. It retries, with a doubling gap of roughly 5 minutes then roughly 10. How many times it retries before giving up is UNKNOWN \u2014 the test ended first. The finding that matters more: the contact STOPS at that step and never reaches the rest of the workflow. In the controlled pair, the contact whose webhook returned 200 completed every later step; the contact whose webhook returned 500 never reached the next step at all. So a dead receiver does not lose data, it halts the workflow for every contact flowing through it. If the webhook is not critical, put it at the END of a workflow, or give it a workflow of its own, so an outage cannot hold up the steps that matter.";
10081
+ var ALLOWED_METHODS = ["POST", "GET", "PUT"];
10082
+ var KeyValueSchema = import_zod34.z.object({
10083
+ key: import_zod34.z.string().describe("Key name. Cannot be empty."),
10084
+ value: import_zod34.z.string().describe("Value. Merge fields such as {{contact.first_name}} resolve at send time.")
10085
+ });
10086
+ var EventSchema = import_zod34.z.union([
10087
+ import_zod34.z.string().describe("A GHL trigger type, e.g. 'contact_created'. Fires with no filter."),
10088
+ import_zod34.z.object({
10089
+ type: import_zod34.z.string().describe("A GHL trigger type, e.g. 'contact_tag' or 'pipeline_stage_updated'."),
10090
+ conditions: import_zod34.z.array(import_zod34.z.record(import_zod34.z.unknown())).optional().describe(
10091
+ "Filter conditions, exactly as update_workflow_actions takes them. Use get_trigger_registry to find the valid fields for a trigger type. Omit to fire on every event of that type."
10092
+ )
10093
+ })
10094
+ ]);
10095
+ function buildWebhookAction(input) {
10096
+ const url = (input.url ?? "").trim();
10097
+ if (!url) throw new Error("A webhook needs a 'url'.");
10098
+ if (!/^https?:\/\//i.test(url)) {
10099
+ throw new Error(
10100
+ `Webhook url must start with http:// or https:// (got "${url}"). GHL saves any string here and then never fires.`
10101
+ );
10102
+ }
10103
+ const method = (input.method ?? "POST").toUpperCase();
10104
+ if (!ALLOWED_METHODS.some((m) => m === method)) {
10105
+ throw new Error(`Webhook method must be one of POST, GET, PUT (got "${input.method}").`);
10106
+ }
10107
+ const pairs = (entries, field) => {
10108
+ const list = entries ?? [];
10109
+ for (const entry of list) {
10110
+ if (!entry || typeof entry.key !== "string" || !entry.key.trim()) {
10111
+ throw new Error(`Every ${field} entry needs a non-empty key. GHL only warns about this, then delivers nothing.`);
10112
+ }
10113
+ if (entry.value === void 0 || entry.value === null) {
10114
+ throw new Error(`${field} entry "${entry.key}" needs a value.`);
10115
+ }
10116
+ }
10117
+ return list;
10118
+ };
10119
+ const action = {
10120
+ name: input.name?.trim() || "Webhook",
10121
+ type: "webhook",
10122
+ cat: "actions",
10123
+ attributes: {
10124
+ method,
10125
+ url,
10126
+ customData: pairs(input.customData, "customData"),
10127
+ headers: pairs(input.headers, "headers")
10128
+ }
10129
+ };
10130
+ return input.id ? { ...action, id: input.id } : action;
10131
+ }
10132
+ function buildWebhookTriggers(events) {
10133
+ if (!events?.length) {
10134
+ throw new Error("A webhook needs at least one event \u2014 a workflow with no trigger never fires.");
10135
+ }
10136
+ return events.map((event) => {
10137
+ const type = typeof event === "string" ? event : event.type;
10138
+ if (!type?.trim()) throw new Error("Each event needs a trigger 'type'.");
10139
+ const conditions = typeof event === "string" ? [] : event.conditions ?? [];
10140
+ return WorkflowTriggerSchema.parse({ name: type, type, conditions });
10141
+ });
10142
+ }
10143
+ var KeyValueListSchema = import_zod34.z.array(KeyValueSchema).catch([]);
10144
+ function readKeyValues(value) {
10145
+ const LooseEntry = import_zod34.z.object({ key: import_zod34.z.unknown(), value: import_zod34.z.unknown() }).transform((entry) => ({ key: String(entry.key ?? ""), value: String(entry.value ?? "") }));
10146
+ return KeyValueListSchema.parse(import_zod34.z.array(LooseEntry).catch([]).parse(value));
10147
+ }
10148
+ function replaceWebhookAttributes(templates, target, attributes) {
10149
+ return templates.map((action) => {
10150
+ if (!action.id || action.id !== target.id) return action;
10151
+ if (action.type !== "webhook") return action;
10152
+ return { ...action, attributes };
10153
+ });
10154
+ }
10155
+ function readId(workflow) {
10156
+ const record = workflow;
10157
+ const id = workflow._id ?? record.id;
10158
+ return typeof id === "string" ? id : "";
10159
+ }
10160
+ function summarizeWebhookWorkflow(workflow) {
10161
+ const templates = workflow.workflowData?.templates ?? [];
10162
+ const webhookActions = templates.filter((action) => action.type === "webhook");
10163
+ if (!webhookActions.length) return null;
10164
+ const actions = webhookActions.map((action) => {
10165
+ const attributes = action.attributes ?? {};
10166
+ return {
10167
+ actionId: action.id,
10168
+ name: action.name,
10169
+ url: typeof attributes.url === "string" ? attributes.url : "",
10170
+ method: typeof attributes.method === "string" ? attributes.method : "",
10171
+ customData: readKeyValues(attributes.customData),
10172
+ headers: readKeyValues(attributes.headers)
10173
+ };
10174
+ });
10175
+ return {
10176
+ webhookId: readId(workflow),
10177
+ name: workflow.name,
10178
+ status: workflow.status,
10179
+ events: (workflow.triggers ?? []).map((trigger) => trigger.type),
10180
+ actions,
10181
+ hasOtherActions: templates.length > webhookActions.length
10182
+ };
10183
+ }
10184
+ var WorkflowListSchema = import_zod34.z.object({ rows: import_zod34.z.array(import_zod34.z.object({ _id: import_zod34.z.string().optional(), id: import_zod34.z.string().optional() }).passthrough()).optional() }).passthrough();
10185
+ function registerWebhookTools(server2, builderClient) {
10186
+ const requireClient = () => {
10187
+ if (!builderClient) {
10188
+ throw new Error(
10189
+ "Webhooks run on GHL's workflow builder, which needs Firebase credentials. Run enable_workflow_builder (or health_check) to set them up."
10190
+ );
10191
+ }
10192
+ return builderClient;
10193
+ };
10194
+ const loadWebhooks = async (client, limit) => {
10195
+ const listed = WorkflowListSchema.parse(await client.listWorkflows(limit, 0));
10196
+ const rows = listed.rows ?? [];
10197
+ const webhooks = [];
10198
+ for (const row of rows) {
10199
+ const id = row._id ?? row.id;
10200
+ if (!id) continue;
10201
+ const summary = summarizeWebhookWorkflow(await client.getWorkflow(id));
10202
+ if (summary) webhooks.push(summary);
10203
+ }
10204
+ return { webhooks, scanned: rows.length };
10205
+ };
10206
+ server2.tool(
10047
10207
  "list_webhooks",
10048
- "List all webhooks configured in a GHL location.",
10208
+ "List every webhook in the current sub-account. A webhook here is a workflow that fires a `webhook` action, so this also finds ones built by hand in the GHL UI. Returns the URL, HTTP method and triggering events for each. NOTE: GoHighLevel has no webhook REST API (it is Marketplace-OAuth only) \u2014 webhooks live on the workflow layer, which is why each one has a workflow ID and shows up in the client's Automation list. " + WEBHOOK_PAYLOAD_NOTE,
10049
10209
  {
10050
- locationId: import_zod34.z.string().optional().describe("GHL Location ID. Optional if GHL_LOCATION_ID is set in env.")
10210
+ limit: import_zod34.z.number().int().positive().max(200).optional().describe(
10211
+ "How many workflows to scan. Defaults to 50, capped at 200. Each one is opened individually, so a large scan takes a while \u2014 the response says how many were covered and whether more remain."
10212
+ )
10051
10213
  },
10052
- async ({ locationId: locationId2 }) => {
10053
- const resolvedLocationId = client.resolveLocationId(locationId2);
10054
- return client.get(`/webhooks/`, {
10055
- params: { locationId: resolvedLocationId }
10056
- });
10214
+ async ({ limit }) => {
10215
+ try {
10216
+ const client = requireClient();
10217
+ const { webhooks, scanned } = await loadWebhooks(client, limit ?? 50);
10218
+ return jsonResponse({
10219
+ webhooks,
10220
+ _scan: {
10221
+ workflowsScanned: scanned,
10222
+ note: scanned >= (limit ?? 50) ? `Scanned the first ${scanned} workflows \u2014 there may be more. Raise 'limit' to cover them.` : `Scanned all ${scanned} workflows in this sub-account.`
10223
+ }
10224
+ });
10225
+ } catch (error) {
10226
+ return errorResponse(error);
10227
+ }
10057
10228
  }
10058
10229
  );
10059
- safeTool(
10060
- server2,
10230
+ server2.tool(
10061
10231
  "get_webhook",
10062
- "Retrieve a single webhook by its ID.",
10232
+ "Get one webhook by ID (the ID is its workflow ID, as returned by list_webhooks or create_webhook). Shows the destination URL, method, custom data, custom headers and triggering events. " + WEBHOOK_PAYLOAD_NOTE + " " + WEBHOOK_FAILURE_NOTE,
10063
10233
  {
10064
- webhookId: import_zod34.z.string().describe("The webhook ID to retrieve."),
10065
- locationId: import_zod34.z.string().optional().describe("GHL Location ID. Optional if GHL_LOCATION_ID is set in env.")
10234
+ webhookId: import_zod34.z.string().describe("The webhook ID \u2014 the workflow ID that carries the webhook action.")
10066
10235
  },
10067
- async ({ webhookId, locationId: locationId2 }) => {
10068
- const resolvedLocationId = client.resolveLocationId(locationId2);
10069
- return client.get(`/webhooks/${webhookId}`, {
10070
- params: { locationId: resolvedLocationId }
10071
- });
10236
+ async ({ webhookId }) => {
10237
+ try {
10238
+ const client = requireClient();
10239
+ const summary = summarizeWebhookWorkflow(await client.getWorkflow(webhookId));
10240
+ if (!summary) {
10241
+ return jsonResponse({
10242
+ error: `Workflow ${webhookId} exists but fires no webhook action. Use list_webhooks to see the ones that do.`
10243
+ });
10244
+ }
10245
+ return jsonResponse(summary);
10246
+ } catch (error) {
10247
+ return errorResponse(error);
10248
+ }
10072
10249
  }
10073
10250
  );
10074
- safeTool(
10075
- server2,
10251
+ server2.tool(
10076
10252
  "create_webhook",
10077
- "Create a new webhook subscription for GHL events.",
10253
+ "Create a webhook: GHL POSTs to your URL whenever the events you name happen. Builds a real workflow with a `webhook` action, so the client can see and edit it in Automation. Because it rides the workflow layer, it can fire on ANY of GHL's 57 native triggers, not just the short list GHL's documented webhook API covers. Use `customData` to add your own key/value pairs to the body (merge fields like {{contact.first_name}} resolve), and `headers` for auth headers or signatures. Both work on every plan \u2014 GHL's separate 'Custom Webhook' action is a paid add-on and is only needed to replace GHL's contact body wholesale. " + WEBHOOK_PAYLOAD_NOTE + " " + WEBHOOK_FAILURE_NOTE,
10078
10254
  {
10079
- locationId: import_zod34.z.string().optional().describe("GHL Location ID. Optional if GHL_LOCATION_ID is set in env."),
10080
- name: import_zod34.z.string().describe("Webhook name."),
10081
- url: import_zod34.z.string().describe("The URL to POST events to."),
10082
- events: import_zod34.z.array(import_zod34.z.string()).describe(
10083
- "List of event types to subscribe to (e.g. 'ContactCreate', 'AppointmentCreate', 'OpportunityStatusUpdate')."
10084
- )
10255
+ url: import_zod34.z.string().describe("Destination URL. Must start with http:// or https://."),
10256
+ events: import_zod34.z.array(EventSchema).describe(
10257
+ "Which GHL events fire this webhook. Either a trigger type string ('contact_created') or {type, conditions} for a filtered trigger. Use get_trigger_registry for the 57 types and their fields."
10258
+ ),
10259
+ name: import_zod34.z.string().optional().describe("Name shown in the client's workflow list. Defaults to 'Webhook \u2192 <host>'."),
10260
+ method: import_zod34.z.string().optional().describe("POST (default), GET or PUT."),
10261
+ customData: import_zod34.z.array(KeyValueSchema).optional().describe("Extra key/value pairs added to the body under `customData`."),
10262
+ headers: import_zod34.z.array(KeyValueSchema).optional().describe("Extra HTTP headers sent with the request."),
10263
+ publish: import_zod34.z.boolean().optional().describe("Publish immediately so it starts firing. Defaults to true. False leaves it as a draft.")
10085
10264
  },
10086
- async ({ locationId: locationId2, name, url, events }) => {
10087
- const resolvedLocationId = client.resolveLocationId(locationId2);
10088
- return client.post(`/webhooks/`, {
10089
- body: {
10090
- locationId: resolvedLocationId,
10091
- name,
10092
- url,
10093
- events
10265
+ async ({ url, events, name, method, customData, headers, publish }) => {
10266
+ try {
10267
+ const client = requireClient();
10268
+ const action = buildWebhookAction({ url, method, customData, headers });
10269
+ const triggers = buildWebhookTriggers(events);
10270
+ let host = url;
10271
+ try {
10272
+ host = new URL(url).host;
10273
+ } catch {
10094
10274
  }
10095
- });
10275
+ const workflowName = name?.trim() || `Webhook \u2192 ${host}`;
10276
+ const created = await client.createWorkflow(workflowName);
10277
+ const webhookId = readId(created);
10278
+ await client.updateWorkflow(webhookId, {
10279
+ actions: [action],
10280
+ triggers,
10281
+ status: publish === false ? "draft" : "published"
10282
+ });
10283
+ const summary = summarizeWebhookWorkflow(await client.getWorkflow(webhookId));
10284
+ return jsonResponse({
10285
+ created: summary,
10286
+ note: publish === false ? "Created as a DRAFT \u2014 it will not fire until you publish it." : "Published and live. Fire a test event to confirm it reaches your endpoint."
10287
+ });
10288
+ } catch (error) {
10289
+ return errorResponse(error);
10290
+ }
10096
10291
  }
10097
10292
  );
10098
- safeTool(
10099
- server2,
10293
+ server2.tool(
10100
10294
  "update_webhook",
10101
- "Update an existing webhook (name, URL, or subscribed events).",
10295
+ "Change a webhook's URL, method, custom data, headers, name, events, or published status. Edits the existing action in place rather than replacing it, so the workflow's history and any other steps around it survive. Only the fields you pass change; everything else is left alone.",
10102
10296
  {
10103
- webhookId: import_zod34.z.string().describe("The webhook ID to update."),
10104
- locationId: import_zod34.z.string().optional().describe("GHL Location ID. Optional if GHL_LOCATION_ID is set in env."),
10105
- name: import_zod34.z.string().optional().describe("Updated webhook name."),
10106
- url: import_zod34.z.string().optional().describe("Updated webhook URL."),
10107
- events: import_zod34.z.array(import_zod34.z.string()).optional().describe("Updated list of event types.")
10297
+ webhookId: import_zod34.z.string().describe("The webhook ID (its workflow ID)."),
10298
+ url: import_zod34.z.string().optional().describe("New destination URL."),
10299
+ method: import_zod34.z.string().optional().describe("POST, GET or PUT."),
10300
+ customData: import_zod34.z.array(KeyValueSchema).optional().describe("Replaces the existing custom data entirely."),
10301
+ headers: import_zod34.z.array(KeyValueSchema).optional().describe("Replaces the existing headers entirely."),
10302
+ name: import_zod34.z.string().optional().describe("New name for the workflow."),
10303
+ events: import_zod34.z.array(EventSchema).optional().describe("Replaces the triggering events entirely."),
10304
+ status: import_zod34.z.enum(["draft", "published"]).optional().describe("Publish it or take it back to draft (stops it firing)."),
10305
+ actionId: import_zod34.z.string().optional().describe("Required only when the workflow carries more than one webhook action \u2014 which one to edit.")
10108
10306
  },
10109
- async ({ webhookId, locationId: locationId2, name, url, events }) => {
10110
- const resolvedLocationId = client.resolveLocationId(locationId2);
10111
- const body = { locationId: resolvedLocationId };
10112
- if (name !== void 0) body.name = name;
10113
- if (url !== void 0) body.url = url;
10114
- if (events !== void 0) body.events = events;
10115
- return client.put(`/webhooks/${webhookId}`, { body });
10307
+ async ({ webhookId, url, method, customData, headers, name, events, status, actionId }) => {
10308
+ try {
10309
+ const client = requireClient();
10310
+ const current = await client.getWorkflow(webhookId);
10311
+ const templates = current.workflowData?.templates ?? [];
10312
+ const webhookActions = templates.filter((a) => a.type === "webhook");
10313
+ if (!webhookActions.length) {
10314
+ throw new Error(`Workflow ${webhookId} fires no webhook action. Use list_webhooks to find one that does.`);
10315
+ }
10316
+ if (webhookActions.length > 1 && !actionId) {
10317
+ throw new Error(
10318
+ `Workflow ${webhookId} has ${webhookActions.length} webhook actions (${webhookActions.map((a) => `${a.id}: ${a.name}`).join(", ")}). Pass 'actionId' to say which one to change.`
10319
+ );
10320
+ }
10321
+ const target = actionId ? webhookActions.find((a) => a.id === actionId) : webhookActions[0];
10322
+ if (!target) throw new Error(`No webhook action with id ${actionId} on workflow ${webhookId}.`);
10323
+ const attributes = target.attributes ?? {};
10324
+ const validated = buildWebhookAction({
10325
+ id: target.id,
10326
+ name: target.name,
10327
+ url: url ?? (typeof attributes.url === "string" ? attributes.url : ""),
10328
+ method: method ?? (typeof attributes.method === "string" ? attributes.method : "POST"),
10329
+ customData: customData ?? readKeyValues(attributes.customData),
10330
+ headers: headers ?? readKeyValues(attributes.headers)
10331
+ });
10332
+ const nextActions = replaceWebhookAttributes(templates, target, validated.attributes);
10333
+ await client.updateWorkflow(webhookId, {
10334
+ actions: nextActions,
10335
+ ...events ? { triggers: buildWebhookTriggers(events) } : {},
10336
+ ...name ? { name } : {},
10337
+ ...status ? { status } : {}
10338
+ });
10339
+ return jsonResponse({ updated: summarizeWebhookWorkflow(await client.getWorkflow(webhookId)) });
10340
+ } catch (error) {
10341
+ return errorResponse(error);
10342
+ }
10116
10343
  }
10117
10344
  );
10118
- safeTool(
10119
- server2,
10345
+ server2.tool(
10120
10346
  "delete_webhook",
10121
- "Delete a webhook subscription by ID.",
10347
+ "Delete a webhook. This deletes the whole workflow that carries it, so it refuses when that workflow also does other things (sends an SMS, moves an opportunity) unless you pass force. IRREVERSIBLE. To stop a webhook firing without destroying it, use update_webhook with status 'draft'.",
10122
10348
  {
10123
- webhookId: import_zod34.z.string().describe("The webhook ID to delete."),
10124
- locationId: import_zod34.z.string().optional().describe("GHL Location ID. Optional if GHL_LOCATION_ID is set in env.")
10349
+ webhookId: import_zod34.z.string().describe("The webhook ID (its workflow ID)."),
10350
+ confirm: import_zod34.z.literal("DELETE").describe("Must be 'DELETE' to confirm this destructive action."),
10351
+ force: import_zod34.z.boolean().optional().describe("Delete even when the workflow contains other actions that would go with it.")
10125
10352
  },
10126
- async ({ webhookId, locationId: locationId2 }) => {
10127
- const resolvedLocationId = client.resolveLocationId(locationId2);
10128
- return client.delete(`/webhooks/${webhookId}`, {
10129
- params: { locationId: resolvedLocationId }
10130
- });
10353
+ async ({ webhookId, confirm, force }) => {
10354
+ try {
10355
+ const client = requireClient();
10356
+ if (confirm !== "DELETE") throw new Error("Pass confirm: 'DELETE' to delete a webhook.");
10357
+ const summary = summarizeWebhookWorkflow(await client.getWorkflow(webhookId));
10358
+ if (!summary) {
10359
+ throw new Error(
10360
+ `Workflow ${webhookId} fires no webhook action, so this tool will not delete it. Use delete_workflow_full if you really mean to delete that workflow.`
10361
+ );
10362
+ }
10363
+ if (summary.hasOtherActions && !force) {
10364
+ throw new Error(
10365
+ `Workflow "${summary.name}" does more than fire a webhook \u2014 deleting it would delete those steps too. Pass force: true if that is what you want, or use update_webhook with status 'draft' to just stop it firing.`
10366
+ );
10367
+ }
10368
+ await client.deleteWorkflow(webhookId);
10369
+ return jsonResponse({ deleted: true, webhookId, name: summary.name });
10370
+ } catch (error) {
10371
+ return errorResponse(error);
10372
+ }
10131
10373
  }
10132
10374
  );
10133
10375
  }
@@ -19096,7 +19338,6 @@ var publicApiTools = [
19096
19338
  [registerAssociationTools, "associations"],
19097
19339
  [registerEstimateTools, "estimates"],
19098
19340
  [registerCouponTools, "coupons"],
19099
- [registerWebhookTools, "webhooks"],
19100
19341
  [registerDocumentTools, "documents"],
19101
19342
  [registerBulkOperationTools, "bulk-operations"],
19102
19343
  [registerTemplateDeployerTools, "template-deployer"],
@@ -19108,6 +19349,12 @@ var internalApiTools = [
19108
19349
  [registerFunnelBuilderTools, "funnel-builder"],
19109
19350
  [registerPipelineBuilderTools, "pipeline-builder"],
19110
19351
  [registerWorkflowClonerTools, "workflow-cloner"],
19352
+ // Webhooks moved here in v3.66.0. GHL has no webhook REST resource — the
19353
+ // old public-API calls 404'd for every customer since 2026-03-23 — so they
19354
+ // are now built on the workflow builder, which needs Firebase auth. The
19355
+ // module registers unconditionally (the tools explain the missing
19356
+ // credentials rather than vanishing), so the advertised tool count holds.
19357
+ [registerWebhookTools, "webhooks"],
19111
19358
  [registerSmartListTools, "smart-lists"],
19112
19359
  [registerReputationTools, "reputation"],
19113
19360
  [registerEmailCampaignTools, "email-campaigns"],
package/guide/guide.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>GHL Command — User Guide</title>
7
- <!-- guides-hash: b69654ea29507200 -->
7
+ <!-- guides-hash: a156991f1141b0d5 -->
8
8
  <style>
9
9
  /* Deliberately light in every environment, including a dark-mode OS. This is a
10
10
  reference document people read at length, print, and save to PDF, and a page
@@ -226,7 +226,7 @@ body.app.reading #indexview{display:none}
226
226
  <p class="eyebrow">GHL Command</p>
227
227
  <h1>What do you want to get done?</h1>
228
228
  <p class="sub">Every guide is a job, in plain English: open one, copy a prompt, paste it to Claude. A job is not a button, so one guide usually puts a dozen of GHL Command's tools to work at once. Every prompt marked "Proven live" was run against a real GoHighLevel account before it shipped.</p>
229
- <div class="badges"><span class="badge on">27 guides</span><span class="badge">10 categories</span><span class="badge">Updates with the product</span></div>
229
+ <div class="badges"><span class="badge on">28 guides</span><span class="badge">10 categories</span><span class="badge">Updates with the product</span></div>
230
230
  </div></section>
231
231
  <div class="shell">
232
232
  <main>
@@ -241,7 +241,8 @@ body.app.reading #indexview{display:none}
241
241
  <a class="card pub" href="#g-ai-bots-and-custom-values" data-k="conversation ai ai bot knowledge base bot goals custom values merge fields voice ai agency name snapshot template personality prompt"><h3>What your AI bot knows, and where to put it</h3><p>Three places a bot reads from, one of which silently ignores your custom values.</p><div class="meta"><span class="pill free">Free plan</span><span class="time">~5 minutes</span><span class="open">Open guide &#8594;</span></div></a>
242
242
  <a class="card pub" href="#g-audit-your-workflows" data-k="audit workflows broken silent failure check review existing validate not sending nothing happens"><h3>Audit your workflows</h3><p>Find silent failures and dead ends in existing workflows before your client does.</p><div class="meta"><span class="pill free">Free plan</span><span class="time">~2 minutes</span><span class="open">Open guide &#8594;</span></div></a>
243
243
  <a class="card pub" href="#g-branch-on-tags-and-fields" data-k="if else branch condition vip tag field split two paths different message"><h3>Branch on tags and fields</h3><p>If they are a VIP, send this. Otherwise, send that. Built correctly the first time.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~5 minutes</span><span class="open">Open guide &#8594;</span></div></a>
244
- <a class="card pub" href="#g-build-nurture-sequence" data-k="nurture sequence drip texts emails follow up workflow build quiet hours sending window contact hours"><h3>Build a nurture sequence</h3><p>A follow-up machine that texts, emails, waits, and stops the second someone replies.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~15 minutes</span><span class="open">Open guide &#8594;</span></div></a></div></div>
244
+ <a class="card pub" href="#g-build-nurture-sequence" data-k="nurture sequence drip texts emails follow up workflow build quiet hours sending window contact hours"><h3>Build a nurture sequence</h3><p>A follow-up machine that texts, emails, waits, and stops the second someone replies.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~15 minutes</span><span class="open">Open guide &#8594;</span></div></a>
245
+ <a class="card pub" href="#g-send-data-to-other-apps" data-k="webhook zapier make integromat slack google sheets api send data integration notify push connect n8n other app"><h3>Send GHL data to another app</h3><p>Push contacts into Slack, Sheets, Zapier, Make or your own software the moment something happens in GHL.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~2 minutes</span><span class="open">Open guide &#8594;</span></div></a></div></div>
245
246
  <div class="catsec" id="cat-pipelines-and-sales"><h2>Pipelines and sales</h2><div class="cards"><a class="card pub" href="#g-documents-and-contracts" data-k="documents contracts proposals signature send sign agreement esign template"><h3>Documents and contracts</h3><p>Proposals and contracts sent for signature and tracked from Claude.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~5 minutes</span><span class="open">Open guide &#8594;</span></div></a>
246
247
  <a class="card pub" href="#g-invoices-and-payments" data-k="invoice bill billing payment paid product price coupon promo code discount transactions orders revenue estimate"><h3>Bill a client and see what you were paid</h3><p>Products, invoices, coupons, and the money reads, without opening the billing screen.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~5 minutes</span><span class="open">Open guide &#8594;</span></div></a>
247
248
  <a class="card pub" href="#g-stand-up-a-pipeline" data-k="pipeline stages create new client setup opportunity sales process deal stages"><h3>Stand up a pipeline</h3><p>Stages, fields, and tags for a new client in one conversation.</p><div class="meta"><span class="pill full">Full license</span><span class="time">~10 minutes</span><span class="open">Open guide &#8594;</span></div></a>
@@ -1316,6 +1317,64 @@ Do not reply to anyone. This is a read.</p></div>
1316
1317
  </main></div>
1317
1318
  </section>
1318
1319
 
1320
+ <section class="gpage" id="g-send-data-to-other-apps">
1321
+ <div class="hero"><div class="hero-in">
1322
+ <p class="eyebrow">GHL Command User Guide</p>
1323
+ <h1>Send GHL data to another app</h1>
1324
+ <p class="sub">Push contacts into Slack, Sheets, Zapier, Make or your own software the moment something happens in GHL.</p>
1325
+ <div class="badges"><span class="badge on">Full license</span><span class="badge">~2 minutes</span><span class="badge">Automations</span></div>
1326
+ </div></div>
1327
+ <div class="shell"><main class="gview">
1328
+ <a class="back" href="#top">&#8592; All guides</a>
1329
+ <div class="gsec"><h2>What you'll get</h2>
1330
+ <ul>
1331
+ <li>A live connection that pushes contact details out of GHL into any app that can receive a web request, the moment the thing you care about happens.</li>
1332
+ <li>It can fire on almost anything GHL knows about, not just a new contact: a tag added, a pipeline stage changed, an appointment no showed, a call missed, a form submitted.</li>
1333
+ <li>It shows up in the client's own Automation list, so they can see it, pause it, and edit it without you.</li>
1334
+ </ul>
1335
+ </div>
1336
+ <div class="gsec"><h2>Say this</h2>
1337
+ <div class="prompt"><q>Send every new contact to my Slack channel.</q><button class="copy" type="button">Copy</button></div>
1338
+ <div class="prompt"><q>When a contact moves to Consultation Booked, post them to this address: https://hooks.zapier.com/...</q><button class="copy" type="button">Copy</button></div>
1339
+ <div class="prompt"><q>Show me every webhook in this account and where each one is sending data.</q><button class="copy" type="button">Copy</button></div>
1340
+ </div>
1341
+ <div class="gsec"><h2>The pro prompt</h2>
1342
+ <div class="pro"><div class="pro-head"><b>The exact ask we run on real client accounts</b><span class="pill full">Proven live</span><button class="copy" type="button">Copy</button></div><p class="pro-body">Set up a webhook in my [Radiance Med Spa] account.
1343
+ Fire it whenever a contact gets the tag [hot-lead], and send it to [https://example.com/my-receiver].
1344
+ Add my own fields to what it sends: [clinic] set to [Radiance Med Spa], and [greeting] set to the contact's first name.
1345
+ Then show me exactly what will land at the other end, and tell me what happens if my receiver is down.</p></div>
1346
+ <p class="gpara">Everything in [brackets] is yours to change. Ask for the last part every time. What GHL sends is not what most people expect.</p>
1347
+ </div>
1348
+ <div class="gsec"><h2>What happens when you ask</h2>
1349
+ <ol class="steps">
1350
+ <li>Claude builds a small workflow in the account: your chosen trigger, and one step that sends the data out.</li>
1351
+ <li>It publishes it, so it starts working immediately, and hands you back its ID.</li>
1352
+ <li>Ask it to fire a test, and it will tag a throwaway contact so you can watch the request land before you trust it with real leads.</li>
1353
+ </ol>
1354
+ </div>
1355
+ <div class="gsec"><h2>Good to know</h2>
1356
+ <ul>
1357
+ <li><b>What arrives is a fixed set of contact fields</b>, plus the sub-account details, plus which workflow sent it, plus any custom fields you asked for. Your own fields can include merge fields, so &quot;the contact's first name&quot; arrives already filled in.</li>
1358
+ <li><b><code>tags</code> arrives as one comma separated line of text, not a list.</b> This catches people out constantly. If the receiving app checks whether tags contains &quot;vip&quot;, it will also match a contact tagged &quot;vip-waitlist&quot;. Split on the comma first.</li>
1359
+ <li><b>If the receiving app is down, GHL keeps trying</b>, roughly five minutes later, then ten minutes after that. Nothing is thrown away.</li>
1360
+ <li><b>But the contact stops there while it retries, and so does everything after it in that workflow.</b> If the send is not critical, put it at the end of the workflow, or give it a workflow of its own, so an outage at the other end cannot hold up your follow up.</li>
1361
+ <li>GHL has a paid &quot;Custom Webhook&quot; step that lets you replace the whole message with your own format. You almost never need it. Adding your own fields, your own headers, and choosing POST, GET or PUT all work on every plan.</li>
1362
+ </ul>
1363
+ </div>
1364
+ <div class="gsec"><h2>Where people go wrong</h2>
1365
+ <div style="overflow-x:auto"><table class="wrongtbl"><tr><th>What happened</th><th>The fix</th></tr><tr><td>&quot;I deleted the webhook and it deleted my whole nurture.&quot;</td><td>It will refuse to do that. A webhook here is a workflow, so if that workflow does other things too, Claude stops and tells you instead of deleting it. To just switch a webhook off, ask to set it back to draft.</td></tr><tr><td>&quot;My receiver only got some of the leads.&quot;</td><td>Something earlier in the workflow is holding contacts up, or the send is behind a step that failed. Ask Claude to audit that workflow.</td></tr><tr><td>&quot;My code crashed on <code>tags.map</code>.&quot;</td><td><code>tags</code> is text, not a list. Split it on commas first.</td></tr><tr><td>&quot;I pointed it at a test address and forgot.&quot;</td><td>Ask for every webhook in the account and where each one sends. Old test addresses are the most common thing this turns up.</td></tr></table></div>
1366
+ </div>
1367
+ <div class="gsec"><h2>Related guides</h2>
1368
+ <ul>
1369
+ <li>audit-your-workflows</li>
1370
+ <li>build-nurture-sequence</li>
1371
+ <li>branch-on-tags-and-fields</li>
1372
+ </ul>
1373
+ </div>
1374
+ <p class="gfoot">Works in Claude Desktop and Claude Code. Stuck? Email support@ghlcommand.com and a human answers.</p>
1375
+ </main></div>
1376
+ </section>
1377
+
1319
1378
  <section class="gpage" id="g-send-texts-and-emails">
1320
1379
  <div class="hero"><div class="hero-in">
1321
1380
  <p class="eyebrow">GHL Command User Guide</p>
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@elitedcs/ghl-mcp",
3
- "version": "3.65.3",
3
+ "version": "3.66.0",
4
4
  "mcpName": "io.github.drjerryrelth/ghl-command",
5
- "description": "GoHighLevel MCP Server for Claude. 238 tools full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
5
+ "description": "GoHighLevel MCP Server for Claude. 238 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "ghl-mcp": "dist/index.js"