📡 API Reference · v2 · LeadConnector

The GoHighLevel API,
as the IEXDG stack actually uses it

Not the marketing version. The real one: the endpoints we call, the payloads that work, the headers that are mandatory, and the exact 422s and 401s that cost us hours, written down so they never cost them again.

Maintained for Dr. DNicole Fields, Ed.D. · IEXDG location N5N9WnYQGjQgzGQlWeSc · by Dove Web Consulting

🔗
v2
LeadConnector API
100/10s
Burst limit / resource
📊
200k
Daily cap / resource
🧠
2
Surfaces wired live

The 422 that stranded her drafts

For weeks, IEXDG content sat stuck as drafts and the "push to the social planner" looked broken. The cause was one wrong assumption about a single endpoint: that you update a GHL post with a partial PATCH. You do not. GHL rejects it with a 422, the body never saves, and the draft is stranded with no media. Three more 422s and a Cloudflare 1010 later, the full picture emerged. This document is that picture, so the next person spends zero hours rediscovering it.

The one-paragraph version

GHL v2 writes want a full-payload PUT, never a partial PATCH. Media type must be a real MIME (image/jpeg), never the literal "image", and the thumbnail field the GET hands you must be dropped before you send it back. The post you read comes wrapped under results.post. Blog drafts are invisible in the list unless you pass status=DRAFT, and you cannot GET a single blog post at all. Every /blogs/* call needs a Chrome User-Agent or Cloudflare blocks it with a 1010.

Base URL, token, and the headers that are not optional

The v2 (LeadConnector) API replaced v1 (public-api.gohighlevel.com), which reached end of support on Dec 31, 2025. Everything below is v2.

PieceValueNotes
Base URLhttps://services.leadconnectorhq.comAll v2 calls.
Auth headerAuthorization: Bearer <token>Same scheme for OAuth access tokens and Private Integration Tokens.
Token (IEXDG)pit-•••••• (redacted)A Private Integration Token. Live value lives ONLY in /etc/default/iexdg-mcp as GHL_API_KEY. Never hardcode it in a public doc or repo.
Version headerVersion: 2021-07-28Mandatory. This is the stable value the IEXDG stack uses on every call. (Docs also list 2023-02-21 as current; 2021-07-28 is what is proven here.)
Content / Acceptapplication/jsonOn every JSON request.
User-AgentMozilla/5.0 ... Chrome/124 ...Mandatory for /blogs/* (Cloudflare). See below.

Three token types, one we use

Private Integration Token (PIT) · single-location, internal, what IEXDG uses. OAuth 2.0 · required for public Marketplace apps and agency / multi-account access. Legacy API Keys · deprecated; new ones can no longer be generated. Agency-level vs location-level scope is set when the token is issued.

OAuth 2.0 (Marketplace apps + multi-location)

IEXDG uses a Private Integration Token (single location, no handshake, what every script here uses). A Marketplace app, or any integration spanning multiple sub-accounts, uses OAuth 2.0 instead. The Bearer scheme on calls is identical; only how you obtain and refresh the token differs.

🔗
Connect
user installs / authorizes the app
↩️
?code=
GHL redirects back with a code
🔑
Exchange
POST /oauth/token for tokens
🔄
Store + refresh
refresh before the ~24h expiry
# Exchange the code (and later, refresh) at the CORRECT endpoint:
POST https://services.leadconnectorhq.com/oauth/token
Content-Type: application/x-www-form-urlencoded
{
  "client_id": "...", "client_secret": "...",
  "grant_type": "authorization_code",   # or "refresh_token"
  "code": "<the ?code>",               # or "refresh_token": "<saved>"
  "redirect_uri": "https://your.app/callback",
  "user_type": "Location"            # or "Company" for agency-level
}
# -> { access_token, refresh_token, expires_in (~86399s), locationId, companyId, userType }
ThingValue
Token endpointPOST /oauth/token (no auth header; form-encoded)
Access token life~24h (store the ABSOLUTE expiry = now + expires_in; refresh ~5 min early)
Refresh tokenlong-lived; each refresh returns a NEW refresh token, persist it
Agency → locationPOST /oauth/locationToken (mint a sub-account token from an agency token)
List installsGET /oauth/installedLocations

Wrong things seen in generated OAuth code (do not copy them)

The token URL is https://services.leadconnectorhq.com/oauth/token, NOT a bare https://leadconnectorhq.com. v2 paths have no /v2/ prefix. Contact update is PUT /contacts/{id}, custom values is PUT /locations/{id}/customValues/{id} (camelCase, not custom-values). Use Version: 2021-07-28.

Cloudflare 1010: send a Chrome User-Agent

GHL fronts its API with Cloudflare. A request with a default library user-agent (Python urllib, some HTTP clients) is bounced with Error 1010, "Access denied, the site owner has blocked access based on your browser's signature." This is not an auth error. The token is fine. The signature is the problem.

# Mandatory on /blogs/* . Tolerated-optional on /social-media-posting/* ,
# but send it everywhere so you never get surprised by a 1010.
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
             "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"

Observed

The blog inventory and blog-edit calls returned 1010 until the Chrome UA was added. The social-media-posting probes worked without it. Rule of thumb: always send the UA; it never hurts and it is required for blogs.

100 per ten seconds, 200,000 per day

The documented v2 limits
  • Burst: 100 requests per 10 seconds, per resource.
  • Daily: 200,000 requests per day, per resource.

Responses carry: X-RateLimit-Max, X-RateLimit-Remaining, X-RateLimit-Limit-Daily, X-RateLimit-Daily-Remaining, X-RateLimit-Interval-Milliseconds.

The IEXDG discipline (stricter on purpose)
  • time.sleep(2) between EVERY call.
  • Max ~20 requests / minute.
  • > 50 calls in a loop: ask first.

Robert's IP has been Cloudflare-blocked from rapid calls before. The 2-second floor is cheaper than a block.

Every GHL surface, and which ones IEXDG wires live

The v2 API is broad. IEXDG actively calls two surfaces today (Social Media Posting and Blogs) plus Media upload. The rest are documented as available for when the stack reaches them.

SurfaceStatus in IEXDGWhat it does
Social Media PostingLiveCreate / read / update / schedule / delete posts across IG, FB, LinkedIn, Google.
BlogsLiveCulture Talkz blog: list sites + posts, create, update, slug-check.
Media LibraryUsedUpload an image, get a CDN URL to attach to a post.
ContactsAvailableFull CRUD on contacts / leads. (Dashboard contact tools wrap this.)
ConversationsAvailableSMS, email, call messaging.
Calendars & EventsAvailableAppointment scheduling / booking.
OpportunitiesAvailableSales pipeline / deal management.
PaymentsAvailableTransactions / subscriptions.
WorkflowsAvailableAutomation triggers / actions.
WebhooksAvailableReal-time events (50+ topics).

Per-resource base paths for the "Available" surfaces are exposed only in GHL's JS-rendered marketplace docs and were not machine-fetchable. They are listed here as known-present, not as verified paths. Verify the path against the live docs before first use.

Social Media Posting

Path root: /social-media-posting/{locationId}/posts. Every write takes the SAME full body shape. There is no partial update.

POST /social-media-posting/{loc}/posts · create

Drafts only by policy (Rule 12: Dr. DNicole reviews before publish). All of accountIds, userId, and createdBy are required; createdBy must mirror userId.

POST /social-media-posting/N5N9WnYQGjQgzGQlWeSc/posts
{
  "accountIds": ["6626...IG composite id"],   # locPrefix_LOCATION_igUserId
  "summary":    "the post caption text",
  "media":      [{ "url": "https://.../hero.jpg", "type": "image/jpeg" }],
  "type":       "post",
  "status":     "draft",
  "userId":     "q6G7tGmee1Bf6CUIGSMw",
  "createdBy":  "q6G7tGmee1Bf6CUIGSMw",
  "scheduleDate": "2030-01-01T15:00:00.000Z"
}
# 201 -> new id at results.post._id
GET /social-media-posting/{loc}/posts/{id} · read (mind the envelope)

The post is nested under results.post, not at the top level. Parse defensively or you will read an empty accountIds / media and then send a broken update.

def get_post(j):
    res = j.get("results") or j
    return res.get("post") or res   # the post lives here
PUT /social-media-posting/{loc}/posts/{id} · update (the correct path)

Do not PATCH

A partial PATCH with {summary, scheduleDate} returns 422: "property scheduleDate should not exist", "property summary should not exist". This is the bug that stranded every draft. Use a full-payload PUT.

GET the live post first, preserve its fields, change only what you mean to, normalize media, and PUT the whole object back. Send the same field set as create.

The media normalization law

The GET hands you media as {url, type, thumbnail} with type:"image". GHL rejects that on a scheduled write: 422 "Invalid media format type." Rebuild every item as {url, type} with a real MIME, and drop thumbnail.

def mime(url, t):
    if t and "/" in t: return t        # already a MIME
    u = url.lower().split("?")[0]
    for ext, mt in ((".jpg","image/jpeg"),(".jpeg","image/jpeg"),
                  (".png","image/png"),(".gif","image/gif"),
                  (".webp","image/webp"),(".mp4","video/mp4"),
                  (".mov","video/quicktime")):
        if u.endswith(ext): return mt
    return "image/jpeg"
media = [{"url": m["url"], "type": mime(m["url"], m.get("type"))}
         for m in post.get("media", []) if m.get("url")]
Scheduling · the future clamp (never instant-publish)

To approve a draft, PUT it with status:"scheduled" and a future scheduleDate. The IEXDG helper force-clamps any past or missing date to now + 24h so a phone tap can never publish on the spot. Instagram scheduling also requires the post to have a real image, or it 422s with "Instagram Account, would require image or video."

Each post carries its OWN date, or they all collapse onto the run date

On create, scheduleDate defaults to a placeholder (today 9am ET) that she overrides at review. That is fine for the daily cron, which fires one post on its own day. But if you batch-create many future days in a single run, you must pass each post its own planned scheduleDate, or every draft lands on the run date (the "everything is dated the 3rd" bug). To repair posts already stamped wrong, GET each one and full-PUT it back with the correct date and a varied time (Rule 13: never batch the same hour).

DELETE /social-media-posting/{loc}/posts/{id} & POST /medias/upload-file

Delete returns 200 and soft-deletes (a later GET shows deleted:true). Media upload: multipart POST /medias/upload-file?locationId={loc} returns a CDN URL; attach it as a media item on a subsequent PUT. Re-read accountIds from the envelope before re-PUTting, or the attach fails. Size cap: GHL rejects oversized files with 413 FILE_SIZE_EXCEEDED; downscale heroes to roughly 2048px / under a few MB before upload (a licensed Shutterstock "huge" jpg at 30MB+ will 413).

The complete create / update body, every field

Create and update take the SAME object. Verified against live IEXDG calls (June 2026) and the official Social Planner reference.

{
  "accountIds":    ["<composite account id>"],  # REQUIRED. one post per account, or fan out
  "summary":       "caption / body text",        # the post copy (GET echoes it as summary)
  "media":         [{"url":"https://.../h.jpg","type":"image/jpeg"}], # MIME type, no thumbnail
  "status":        "draft",                     # see status values below
  "type":          "post",                      # post | story | reel (platform dependent)
  "scheduleDate":  "2030-01-01T15:00:00.000Z",   # ISO UTC, future for scheduled
  "followUpComment": "the first / engagement comment", # auto-posts after publish
  "tags":          ["launch","q3"],              # optional, free-text labels
  "categoryId":    "<category _id>",           # optional, from /categories
  "userId":       "q6G7tGmee1Bf6CUIGSMw",       # REQUIRED
  "createdBy":     "q6G7tGmee1Bf6CUIGSMw",       # REQUIRED, mirror userId
  "gmbPostDetails":       {},                  # Google Business: postType, ctaType, ctaUrl, ...
  "instagramPostDetails": {},                  # IG: collaborators, userTags, coverPhoto (reels)
  "tiktokPostDetails":    {}                   # TikTok: privacyLevel, duet/stitch/comment flags
}
Status values

The status field on a post moves through:

"draft"       # created, not queued (IEXDG default, Rule 12)
"scheduled"   # queued for scheduleDate (must be future)
"published"   # live on the platform
"in_review"   # pending approval (postApprovalDetails.approvalStatus)
"in_progress" # mid-publish
"failed"      # platform rejected it (check the error detail)
"deleted"     # soft-deleted, GET still returns it with deleted:true
First comment followUpComment

Yes, GHL has a first-comment field

Set followUpComment in the post body. GHL auto-posts it as the first comment right after the post publishes (the engagement-comment play). Verified June 2026: create returns 201 and a GET echoes followUpComment back. It travels per account, so it lands on LinkedIn, Facebook, and Instagram alike.

POST /social-media-posting/{loc}/posts/list · search

List and filter posts. POST (not GET) with a filter body. Use status to surface drafts the dashboards hide.

POST /social-media-posting/{loc}/posts/list
{ "type":"all", "accountIds":[], "skip":0, "limit":20,
  "fromDate":"2026-06-01", "toDate":"2026-06-30", "includeUsers":true }
# results.posts[] + a count. paginate with skip/limit.
GET /social-media-posting/{loc}/accounts · connected accounts + the composite id

Returns every connected account and group. The accountIds you put on a post are these composite ids, shaped {platformInternalId}_{locationId}_{platformAccountId}{_suffix}. IEXDG live set:

facebook  …_233461576679044_page          # IEXDG FB page
instagram …_17841400351662914              # drdnicole IG
linkedin  …_qcEwNGOtnr_profile             # Dr. DNicole profile
linkedin  …_106238653_page                 # IEXDG company page
google    …_4777217099041521279            # Google Business

DELETE /accounts/{id} disconnects one.

OAuth account attach, per platform

Connecting a new channel is a two step OAuth handshake, one path family per platform (facebook, instagram, google, linkedin, twitter, tiktok, tiktok-business).

GET  /social-media-posting/oauth/{platform}/start?locationId={loc}&userId={uid}
       # returns the consent URL; user authorizes
GET  /social-media-posting/oauth/{loc}/{platform}/accounts/{accountId}
       # lists the pages / profiles available to attach
POST /social-media-posting/oauth/{loc}/{platform}/accounts
       # attaches the chosen page(s) to the location
CSV bulk posting

Schedule many posts from a spreadsheet: upload, validate, set the target accounts, then finalize.

POST   /social-media-posting/{loc}/csv            # multipart upload, returns csvId + parsed rows
GET    /social-media-posting/{loc}/csv            # list prior uploads
GET    /social-media-posting/{loc}/csv/{csvId}    # review parsed posts before commit
POST   /social-media-posting/{loc}/set-accounts   # bind accounts, then finalize the schedule
DELETE /social-media-posting/{loc}/csv/{csvId}                 # discard an upload
DELETE /social-media-posting/{loc}/csv/{csvId}/post/{postId}   # drop one row
Tags and categories
GET  /social-media-posting/{loc}/categories?searchText=&limit=&skip=
GET  /social-media-posting/{loc}/categories/{id}
GET  /social-media-posting/{loc}/tags?searchText=&limit=&skip=
POST /social-media-posting/{loc}/tags/details   { "tagIds":["…"] }

Categories drive the recurring "category queue" (an evergreen-bucket scheduler). A post carries one categoryId and any number of tags.

The live response shape (verified GET)

A real results.post from June 2026, so you know exactly what comes back:

{ "_id", "locationId", "platform", "accountIds":[…],
  "summary", "media":[{"url","type","id"}], "status",
  "scheduleDate", "displayDate", "followUpComment", "type",
  "tags":[], "categoryId", "source":"composer", "channel",
  "parentPostId", "isCommentSyncing", "createdBy", "user",
  "insights":{"like","share","comment"}, "ogTagsDetails":{},
  "postApprovalDetails":{"approverUser","approvalStatus"},
  "gmbPostDetails":{}, "tiktokPostDetails":{}, "instagramPostDetails":{},
  "deleted", "createdAt", "updatedAt" }
# wrapped: { success, statusCode, message, results:{post:{...}}, traceId }

Source: official Social Planner API reference (marketplace.gohighlevel.com/docs/ghl/social-planner), cross-checked against live IEXDG calls. Endpoint groups on that surface: Post, Account, CSV, OAuth, Category, Tag, Statistics, Category Queue.

Blogs

Path root: /blogs. The API manages blog POSTS. The blog SITE is created in the UI. Authors and categories are listable via API but created in the UI (empty until then), and their IDs go on the post. IEXDG blog: "Culture Talkz", id R8s26XFx86cjOv9G0mJu. Every /blogs/* call needs the Chrome User-Agent (Cloudflare). blogId is REQUIRED on create, on update, and on the posts list (GET /blogs/posts/all). The ONLY endpoint that rejects blogId is the slug-check (passing it there returns 422 "property blogId should not exist"). Get the blogId from GET /blogs/site/all.

CallEndpointNotes & gotchas
List sitesGET /blogs/site/all?locationId=&skip=0&limit=25Sites under data[]. This is where you get the blogId.
List postsGET /blogs/posts/all?locationId=&blogId=&limit=50&offset=0Hides DRAFTs unless you add &status=DRAFT. limit max 50 (over = 422). Array comes back under key blogs. Does NOT return rawHTML.
Get ONE postGET /blogs/posts/{id}Unsupported 401 "not yet supported by the IAM Service." Always list and filter by _id.
Slug free?GET /blogs/posts/url-slug-exists?locationId=&urlSlug=Returns {exists: true|false}. Do NOT pass blogId (422 "property blogId should not exist"). Run before create/update.
Create postPOST /blogs/postsFull body (below). 201. New id in the returned post.
Update postPUT /blogs/posts/{id}FULL body, same shape as create. List first and preserve fields. Returns the post under updatedBlogPost. Edits any status, including PUBLISHED.
List authorsGET /blogs/authors?locationId=&limit=&offset={authors:[], count}. No /all suffix (that 404s). Empty until authors are made in the UI; the id goes in the post author field.
List categoriesGET /blogs/categories?locationId=&limit=&offset={categories:[], count}. Same shape and rules; ids go in categories[].
POST /blogs/posts          # PUT /blogs/posts/{id} uses the SAME full shape
{
  "title": "I Wore a Heart Monitor to Work",
  "locationId": "N5N9WnYQGjQgzGQlWeSc",
  "blogId": "R8s26XFx86cjOv9G0mJu",
  "imageUrl": "https://brain.iexdg.com/.../hero.jpg",
  "imageAltText": "alt text for the hero",
  "urlSlug": "heart-monitor-to-work",   # slug-check it first (no blogId)
  "description": "meta description",
  "rawHTML": "<p>the full post body</p>",  # write-only, see trap below
  "status": "DRAFT",                  # DRAFT | PUBLISHED | SCHEDULED
  "author": "",                       # optional, a UI-made author id
  "categories": [],                  # optional, UI-made category ids
  "publishedAt": "2026-06-03T09:00:00.000Z"
}

Post object fields (from a list item): _id, title, urlSlug, description, imageUrl, imageAltText, status, categories[], author, publishedAt, type ("manual"), archived, deleted, updatedAt, updatedBy. status enum: DRAFT, PUBLISHED, SCHEDULED.

Editing published posts · yes

The same PUT /blogs/posts/{id} edits a post in any status, including PUBLISHED (status is just a body field, no separate route). Because the single-post GET is unavailable, list first, find the post by _id, and re-send the full body with your changes.

The rawHTML read-back trap

rawHTML is effectively write-only over the API. The list endpoint omits it and single-post GET 401s, so you cannot read a post's body back. Keep your own source of truth for the body (her Google Doc or the source file), not GHL, and confirm a posted body in the UI or via the PUT echo (updatedBlogPost).

First comment (engagement comment): YES, via followUpComment

The social post create/update body accepts an optional followUpComment string. Verified live: a create with it returns 201 and the GET echoes the stored followUpComment field. So her first comment from the plan CAN be set via the API and automated by the push pipeline, no manual paste. (An older pipeline note said "no first-comment field"; that is outdated.) Applies to social posts; blog posts have no first comment.

Webhooks, dynamic workflows, and the round-trip loop

You cannot change a workflow's STRUCTURE via the API. You extend GHL two other ways: push events out to your own server (webhooks), and change the VALUES a workflow reads (Custom Values) so its behavior adapts without a UI edit.

Workflow Webhook (UI action)Developer Webhook (App portal)
ScopeLocal: only inside that one workflowGlobal: every matching event across the location/agency
SetupA "Custom Webhook" step in the Workflow BuilderConfigured in the HighLevel Developer Portal app
PayloadFully custom (merge fields)Standard JSON predefined by GHL
Auth/verifyCustom headers / query params you setGHL signs it with a public key (verify, see below)
UseMid-workflow data fetch / conditional logicApp integrations, DB sync, global automation
Workflow "Custom Webhook" action (outbound)

Makes an HTTP request when the step is reached. GET / POST / PUT / DELETE. URL, headers, query, and JSON body all support merge fields like {{contact.id}}, {{contact.email}}, {{custom_values.current_promo_offer}}. Auth options: Bearer, API key header, Basic, OAuth2, or none. "Save response from this Webhook" stores the reply for troubleshooting. Use it to call your server mid-workflow, then have your server write back via the API so the next step reads the new values.

Developer webhook events (subscribe in the app portal)

contact.created / updated / deleted · opportunity.created / updated / deleted / stage_changed · appointment.created / updated / cancelled · invoice.created / updated / paid · form.submitted · survey.submitted · user.created / updated / deleted · location.created / updated · app.installed / uninstalled · pipeline + task + order + product events (50+ total).

Verify webhooks with GHL's PUBLIC KEY, not an HMAC of your client secret

This is the most common wrong pattern in generated code. GHL signs developer webhooks asymmetrically. Verify the raw body against GHL's published key:

  • X-GHL-Signature · Ed25519 public-key signature. Preferred / current.
  • X-WH-Signature · legacy RSA public-key signature. Deprecated July 1, 2026.

Flow: if X-GHL-Signature present, verify with the Ed25519 key; else fall back to the RSA key on X-WH-Signature; reject if neither verifies. Computing hmac_sha256(client_secret, body) and comparing to the header will NOT validate real GHL webhooks. Always return 200 fast so GHL does not retry.

Dynamic workflows via Custom Values

Since the workflow structure is fixed, change what it reads. Update a location Custom Value and every message the workflow sends afterward adapts instantly.

PUT /locations/{locationId}/customValues/{id}     # NOT /v2/, NOT custom-values
{ "name": "Current Promo Offer", "value": "20% off with code SUMMER20" }
# In the workflow email/SMS step, use the merge tag:  {{ custom_values.current_promo_offer }}
Custom Fields (contact scope)

Tied to one person. PUT /contacts/{contactId} with a customFields array. Merge tag {{ contact.your_field }}. Use for per-lead data: a balance, an AI-generated answer, a tracking URL.

Custom Values (location scope)

Global to the sub-account. PUT /locations/{locationId}/customValues/{id}. Merge tag {{ custom_values.your_value }}. Use for shared parameters: a promo code, an alert flag, a default link.

The round-trip loop (structural flexibility despite a fixed workflow)
[ Workflow trigger: contact hits a step ] | v [ Custom Webhook step ] --(contact data)--> [ Your server ] | (AI / DB lookup / math GHL cannot do) | [ Next workflow step ] <--(reads new values)-- [ GHL API V2: PUT contact / customValues ]

Trigger fires → webhook sends data to your server → server runs the logic and writes back via the API → the next step injects the new {{ contact.custom_fields.x }} values. This is how the IEXDG brain can sit in the loop (the existing brain webhook receiver, the push pipeline) without GHL needing to support the logic natively.

What each failure actually means

CodeMessageReal cause + fix
403Error 1010, Access deniedCloudflare blocked the user-agent. Add the Chrome UA.
401not yet supported by the IAM ServiceThat route does not exist in the API (e.g. single blog-post GET). Use the list endpoint.
401(from our own edge)Caddy blocked it: the endpoint is not in the brain's @public_read allowlist. Add it. (This is an IEXDG-edge issue, not GHL.)
422property summary/scheduleDate should not existYou used a partial PATCH. Switch to full-payload PUT.
422Invalid media format typemedia type was "image". Use a MIME (image/jpeg) and drop thumbnail.
422media must be an array... / userId must be a stringCreate body missing media (use [] for a text draft) or userId.
422Instagram Account, would require image or videoCannot schedule an IG post with no media. Attach an image first.

The GHL fuckup chronicle

Every line here was a real dead end on the way to a working call. They are kept candid on purpose: the cost was already paid, the least we can do is not pay it twice.

🚫 Partial PATCH

Assumed update was a partial PATCH. It 422s and silently saves nothing. This is why drafts had no body. Full-payload PUT only.

🚫 type: "image"

Sent media back exactly as GET returned it. Scheduling 422'd on "Invalid media format type." MIME required, thumbnail must go.

🚫 Empty post list

Listed blog posts, got zero, assumed the draft was gone. Drafts are hidden unless status=DRAFT. The post was there all along.

🚫 Single-post GET

Tried to GET one blog post to verify content. 401, route unsupported. The list omits rawHTML too, so verify via the PUT echo or the UI.

🚫 results.post

Read the social GET at the top level, saw empty accountIds, sent a broken PUT. The post is under results.post.

🚫 Caddy 401

The phone Approve worked on localhost but 401'd live. The endpoint was not in Caddy @public_read. Any PWA-called path must be allowlisted.

Every GHL v2 resource, pulled from the official spec

All 37 resources below were extracted from HighLevel's public OpenAPI specs (github.com/GoHighLevel/highlevel-api-docs/apps/*.json), the un-gated source of truth. This is the index (124+ representative operations); see each resource's spec for full request bodies. Surfaces IEXDG uses today are tagged Live.

Contacts 9 ops

POST /contacts/searchSearch contacts (advanced filters)contacts.readonly
GET /contacts/search/duplicateGet duplicate contactcontacts.readonly
POST /contacts/upsertCreate or update contactcontacts.write
GET /contacts/business/{businessId}Contacts by businesscontacts.readonly
POST /contacts/bulk/tags/update/{type}Bulk tag updatecontacts.write
POST /contacts/bulk/businessBulk add/remove from businesscontacts.write
PUT /contacts/{id}/tasks/{taskId}/completedToggle task completecontacts.write
GET /contacts/{id}/appointmentsContact appointmentscontacts.readonly
DELETE /contacts/{id}/campaigns/removeAllRemove from all campaignscontacts.write

Conversations 16 ops

GET /conversations/searchSearch conversationsconversations.readonly
POST /conversations/Create conversationconversations.write
PUT /conversations/preferences/custom-subtypes/{id}Update/archive subtypeconversations.write
GET /conversations/preferences/unsubscriptions/statusSubscription statusconversations.readonly
POST /conversations/messagesSend messageconversations/message.write
POST /conversations/messages/inboundAdd inbound messageconversations/message.write
POST /conversations/messages/outboundAdd outbound callconversations/message.write
POST /conversations/messages/review-replyReply to GMB reviewconversations/message.write
GET /conversations/{id}/messagesMessages by conversationconversations/message.readonly
GET /conversations/messages/{id}Get messageconversations/message.readonly
GET /conversations/messages/email/{id}Get email messageconversations/message.readonly
GET /conversations/messages/exportExport messagesconversations/message.readonly
POST /conversations/messages/uploadUpload attachmentsconversations/message.write
PUT /conversations/messages/{id}/statusUpdate message statusconversations/message.write
GET /conversations/messages/{id}/locations/{loc}/recordingGet recordingconversations/message.readonly
GET /conversations/locations/{loc}/messages/{id}/transcriptionGet transcriptionconversations/message.readonly

Conversation AI 1 ops

PATCH /conversation-ai/agents/{id}/followup-settingsUpdate follow-up settingsconversation-ai.write

Objects 2 ops

POST /objects/{schemaKey}/recordsCreate recordobjects/record.write
POST /objects/{schemaKey}/records/searchSearch recordsobjects/record.readonly

Associations 4 ops

POST /associations/relationsCreate relationassociations/relation.write
GET /associations/relations/{recordId}Relations by recordassociations/relation.readonly
DELETE /associations/relations/{relationId}Delete relationassociations/relation.write
GET /associations/key/{key_name}Association by keyassociations.readonly

Opportunities 5 ops

GET /opportunities/pipelinesGet pipelinesopportunities.readonly
GET /opportunities/lost-reasonGet lost reasonsopportunities.readonly
POST /opportunities/Create opportunityopportunities.write
PUT /opportunities/{id}/statusUpdate statusopportunities.write
POST /opportunities/upsertUpsert opportunityopportunities.write

Calendars 5 ops

GET /calendars/{id}/free-slotsGet free slotscalendars.readonly
POST /calendars/events/appointmentsCreate appointmentcalendars/events.write
GET /calendars/eventsGet eventscalendars/events.readonly
GET /calendars/blocked-slotsGet blocked slotscalendars/events.readonly
DELETE /calendars/events/{id}Delete eventcalendars/events.write

Campaigns 1 ops

GET /campaigns/Get campaignscampaigns.readonly

Courses 1 ops

POST /courses/courses-exporter/public/importImport coursesbearer

Blogs Live 7 ops

GET /blogs/site/allList blog sitesblogs/list.readonly
GET /blogs/posts/allList posts (status=DRAFT to see drafts)blogs/posts.readonly
POST /blogs/postsCreate postblogs/post.write
PUT /blogs/posts/{postId}Update post (any status incl PUBLISHED)blogs/post-update.write
GET /blogs/posts/url-slug-existsSlug check (NO blogId, locationId+urlSlug only)blogs/check-slug.readonly
GET /blogs/authorsList authorsblogs/author.readonly
GET /blogs/categoriesList categoriesblogs/category.readonly

Social Planner Live 7 ops

POST /social-media-posting/{loc}/postsCreate post (full payload + followUpComment)socialplanner/post.write
POST /social-media-posting/{loc}/posts/listSearch postssocialplanner/post.readonly
POST /social-media-posting/{loc}/posts/bulk-deleteBulk delete (max 50)bearer
GET /social-media-posting/{loc}/accountsGet connected accountssocialplanner/account.readonly
DELETE /social-media-posting/{loc}/accounts/{id}Delete accountbearer
GET /social-media-posting/{loc}/csv/{id}Get CSV postbearer
(OAuth start) GET .../oauth/{google,facebook,instagram,linkedin,tiktok}/startBegin account connectsocialplanner/oauth.readonly

Media Library Live 6 ops

GET /medias/filesList files/foldersmedias.readonly
POST /medias/upload-fileUpload file (multipart, hosted/fileUrl)medias.write
POST /medias/folderCreate folderbearer
PUT /medias/update-filesBulk updatebearer
PUT /medias/delete-filesBulk delete/trashbearer
DELETE /medias/{id}Delete file/foldermedias.write

Trigger Links 1 ops

GET /links/searchSearch links (Version 2021-04-15)bearer

Invoices 6 ops

POST /invoices/text2payCreate and send (text2pay)invoices.write
POST /invoices/{id}/sendSend invoiceinvoices.write
POST /invoices/{id}/voidVoid invoiceinvoices.write
POST /invoices/{id}/record-paymentRecord manual paymentinvoices.write
POST /invoices/schedule/{id}/scheduleStart recurring scheduleinvoices/schedule.write
POST /invoices/estimate/{id}/invoiceEstimate to invoiceinvoices/estimate.write

Payments 6 ops

GET /payments/ordersList orderspayments/orders.readonly
GET /payments/orders/{id}Get orderpayments/orders.readonly
POST /payments/orders/{id}/record-paymentRecord order paymentpayments/orders.collectPayment
GET /payments/transactionsList transactionspayments/transactions.readonly
GET /payments/subscriptionsList subscriptionspayments/subscriptions.readonly
POST /payments/custom-provider/connectConnect custom providerpayments/custom-provider.write

Products 1 ops

GET /products/reviewsFetch reviewsproducts.readonly

Locations 5 ops

GET /locations/searchSearch sub-accountslocations.readonly
POST /locations/Create sub-accountlocations.write
GET /locations/{id}/timezonesFetch timezoneslocations.readonly
GET /locations/{id}/templatesGet email/sms templateslocations/templates.readonly
POST /locations/{id}/tasks/searchTask searchlocations/tasks.readonly

Companies 1 ops

GET /companies/{id}Get companycompanies.readonly

Users 1 ops

GET /users/searchSearch usersusers.readonly

Custom Fields V2 1 ops

GET /custom-fields/object-key/{objectKey}Fields by object keylocations/customFields.readonly

Forms 3 ops

GET /forms/List formsforms.readonly
GET /forms/submissionsGet submissionsforms.readonly
POST /forms/upload-custom-filesUpload to contact custom fieldsforms.write

Surveys 2 ops

GET /surveys/Get surveyssurveys.readonly
GET /surveys/submissionsGet submissionssurveys.readonly

Funnels 3 ops

GET /funnels/funnel/listList funnelsfunnels/funnel.readonly
GET /funnels/pageList funnel pagesfunnels/page.readonly
GET /funnels/lookup/redirect/listList redirectsfunnels/redirect.readonly
Updating funnel page content (no API: the builder-injection method)

Funnel pages are read-only via the API (the three GET ops above). There is no POST, PUT, or PATCH for page HTML or a Custom Code element. To change a page you drive the GoHighLevel page builder UI with Playwright. This is how the Culture Talkz hub and the Heart Monitor page get updated. Codified in source/strategy/internal/_auto/ghl_paste_save.py (hub inject), ghl_hm_insert.py (Heart Monitor inject), and ghl_publish_only.py (publish). Full round trip: upload images to /medias/upload-file, edit the local HTML, inject via the builder, then publish, then verify the live URL with Playwright. Proven end to end on the hub Jun 22 2026.

  1. Auth: Playwright context with storage_state="ghl_state.json" (a saved login session, lasts about a week). If dead, re-login with ghl_full_login.py (env GHL_USER / GHL_PASS, emails a 6-digit code, poll code.txt).
  2. Open the builder: goto https://highlevel.espeakers.com/location/N5N9WnYQGjQgzGQlWeSc/page-builder/<pageId> (white-label host). Hub page id jI2ONaOdmSiivrI2hEDK; Heart Monitor page id MVVwJ6MChJGccw0jlX1l.
  3. Find the builder iframe: the editable frame is the one whose URL contains page-builder.leadconnectorhq.com. Poll until it shows a Custom HTML/Javascript element.
  4. Open the code editor: in that frame click Custom HTML/Javascript, then Open Code Editor.
  5. Set the HTML via CodeMirror, never by typing: const cm=document.querySelector('.CodeMirror').CodeMirror; cm.setValue(html); cm.save(); cm.refresh(); then also set the underlying <textarea> value and dispatch input + change events.
  6. Save the element: click the modal Save (or Update / Confirm / Done / Apply).
  7. Save the PAGE: press Ctrl+S and watch the "Last saved" stamp change. If it does not move, click the disk icon in the top bar, just left of Publish.
  8. Publish (AUTOMATABLE, do not skip): the Publish button lives inside the page-builder iframe, not on the outer page, so find it with fr.get_by_role("button", name="Publish") (looking on the outer page returns 0, which is the trap). Click it, then click the confirm Publish in the dialog. Codified in ghl_publish_only.py. A page that is ALREADY live (domain attached, like the hub) re-publishes without re-selecting a domain, so the whole flow runs headless. Only the FIRST-EVER publish of a never-published page needs a one-time Select-domain pick in the UI. After publish the content is JS-rendered, so verify with Playwright, not curl.
  9. Adding a NEW Code element to a blank page is drag-only (the Insert Element card throws "not visible" on click). Robert drags it on, then automation pastes.

Images: upload assets first with POST /medias/upload-file (multipart, hosted=false, returns an assets.cdn.filesafe.space fileUrl), then reference that URL inside the injected HTML. Never reference brain.iexdg.com URLs on a customer page.

Workflows 1 ops

GET /workflows/List workflowsworkflows.readonly

Snapshots 2 ops

GET /snapshots/List snapshotsAgency-Access
POST /snapshots/share/linkCreate share linkAgency-Access

Emails 2 ops

GET /emails/scheduleList email campaignsemails/schedule.readonly
POST /emails/builder/dataUpdate templateemails/builder.write

LC Email 1 ops

POST /email/verifyVerify an email addressbearer

OAuth 3 ops

POST /oauth/tokenGet access token (no auth)none
POST /oauth/locationTokenMint location token from agency tokenoauth.write
GET /oauth/installedLocationsList install locationsoauth.readonly

Marketplace 1 ops

GET /marketplace/billing/charges/has-fundsCheck fundscharges.readonly

SaaS 3 ops

GET /saas-api/public-api/locationsLocations by stripeIdAgency-Access
POST /saas-api/public-api/enable-saas/{loc}Enable SaaSAgency-Access
PUT /saas-api/public-api/update-saas-subscription/{loc}Update subscriptionAgency-Access

Phone System 3 ops

GET /phone-system/numbers/location/{loc}/availableAvailable numbersphonenumbers.read
POST /phone-system/numbers/location/{loc}/purchasePurchase numberphonenumbers.write
GET /phone-system/number-poolsList number poolsnumberpools.read

Voice AI 1 ops

GET /voice-ai/dashboard/call-logsList call logsvoice-ai-dashboard.readonly

Ad Manager 6 ops

GET /ad-publishing/facebook/reportingAd reportingadPublishing.readonly
PUT /ad-publishing/facebook/campaignsUpsert campaignadPublishing.write
PUT /ad-publishing/facebook/adsetsUpsert adsetadPublishing.write
PUT /ad-publishing/facebook/ads-v2Upsert adadPublishing.write
POST /ad-publishing/facebook/campaigns/{id}/{pause,resume,duplicate}Campaign controladPublishing.write
GET /ad-publishing/facebook/custom-audienceCustom audiencesadPublishing.readonly

Knowledge Base 2 ops

POST /knowledge-bases/crawlerCrawl + discover pagesLocation-Access
POST /knowledge-bases/crawler/trainTrain pages into KBLocation-Access

Agent Studio 1 ops

POST /agent-studio/agent/{id}/executeExecute agentagent-studio.write

Affiliate Manager 2 ops

GET /affiliate-manager/{loc}/affiliatesList affiliatesaffiliate-manager.readonly
GET /affiliate-manager/{loc}/commissionsList commissionsaffiliate-manager.readonly

Proposals 2 ops

GET /proposals/documentList documentsLocation-Access
POST /proposals/document/sendSend documentLocation-Access

A working create, in the right order

🔑
Headers
Bearer PIT + Version 2021-07-28 + Chrome UA
Throttle
sleep(2) between calls
📝
Full body
all fields, MIME media, future date
PUT to update
never PATCH; re-GET + preserve
# The reusable update helper, distilled. GET -> normalize -> full PUT.
async def ghl_put_full(post_id, summary=None, status=None, schedule=None):
    post = await get_post(post_id)                  # results.post
    if not post or not post.get("accountIds"):
        return False, {"error": "post not found / no account"}
    uid = post.get("userId") or post.get("createdBy") or DEFAULT_UID
    st  = status or post.get("status") or "draft"
    sd  = schedule or post.get("scheduleDate") or future_iso()
    if st == "scheduled": sd = future_iso(sd)        # clamp forward
    body = {"accountIds": post["accountIds"], "summary": summary or post.get("summary",""),
            "media": norm_media(post), "type": post.get("type","post"), "status": st,
            "userId": uid, "createdBy": uid, "scheduleDate": sd}
    return await PUT(f"/social-media-posting/{LOC}/posts/{post_id}", body)

Where the working code lives

Repo: source/scripts/ghl/v21_schedule_endpoints.py (the helper above + schedule/unschedule), create_culture_talkz_post.py (blog create), blog_update_heartmonitor.py (blog PUT). On the VM: /opt/iexdg-mcp/iexdg_api.py wraps these as /api/social_planner/{update_post,schedule,unschedule}. Memory: iexdg_ghl_post_update_put_mime_fix_jun03.

IDs and where the secret lives

NameValue
Location IDN5N9WnYQGjQgzGQlWeSc
Blog ID (Culture Talkz)R8s26XFx86cjOv9G0mJu
Default userId / createdByq6G7tGmee1Bf6CUIGSMw
PIT token/etc/default/iexdg-mcp → GHL_API_KEY (redacted here)
Official docsmarketplace.gohighlevel.com/docs · help.gohighlevel.com (art. 48001060529)

The GHL methods behind the cold-email sync

On 2026-06-16 we wired Instantly cold-email replies into this location. A lead reply now auto-creates or updates a contact here, tagged instantly-05a. This section documents the GoHighLevel side of that build: the marketplace OAuth app install, the contact upsert the connector performs, and the search + delete we used to clean up the test record. live

🔗 It is a Marketplace OAuth app, not a PIT

The Instantly connector authorizes against this location through a GoHighLevel Marketplace OAuth app (marketplace.gohighlevel.com/oauth/chooselocation), brokered by Nango (api.nango.dev/oauth/callback). It is a separate auth path from the Private Integration Token (PIT) the rest of this doc uses. The OAuth scope chosen at install maps to contact create/read on the location.

🔗 The install handshake (GHL side)

🔒 OAuth consent chooselocation + login + 2FA app.gohighlevel.com 🏢 Select sub-account INTEGRAL EXPLORATION loc N5N9...WeSc 🔑 Verify and install grant contact create/read code → Nango exchange ✓ Authorized app linked to the location

📋 The contact methods used

OpWhat it doesNotes
POST /contacts/ (upsert)The connector’s “Create Contact” actionUpserts by email within the location; existing contacts are matched, not duplicated. Effectively create-or-update.
GET /contacts/Search by email / query?locationId=&query=&limit= → contacts[]
DELETE /contacts/{id}Remove a contactReturns {"succeeded": true}. We used it to delete the Test-Step record.

⚠ Two things to know

  • Tags are lowercased. We sent instantly-05A; GHL stored instantly-05a. Filter on the lowercase form.
  • The Test Step writes for real. Running Instantly’s action test created a live contact here (type lead, with the tag). Because the action upserts by email, repeated tests collapse to one record. Always delete it after.
# Auth used for the cleanup: PIT Bearer + Version + Chrome UA (same as the rest of this doc)
h = {"Authorization": f"Bearer {PIT}", "Version": "2021-07-28",
     "Accept": "application/json", "User-Agent": "Mozilla/5.0 ... Chrome/124.0 Safari/537.36"}

# 1. find the test contact the Instantly action created
GET  https://services.leadconnectorhq.com/contacts/?locationId={LOC}&query=lead.email@example.com&limit=5
       # -> {"contacts":[{"id":"pavZ...OTEB","email":...,"tags":["instantly-05a"]}]}

# 2. delete it
DELETE https://services.leadconnectorhq.com/contacts/pavZgkSHB5wjnLMkOTEB
       # -> {"succeeded": true}

The full build, end to end

Instantly side (OAuth handshake, the live automation, trigger/action labels, the save + activate gotchas): see the companion Instantly v2 API Reference · section 08. Driver script: source/strategy/internal/_auto/inst_finalize.py. Memory: iexdg_instantly_ghl_oauth_connected_jun16. Live automation: Lead Replied to GHL Create Contact — a Campaign 05 reply now lands here as a tagged contact, hands-off.