Surface 01 · Live
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.
Surface 02 · Live
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.
| Call | Endpoint | Notes & gotchas |
| List sites | GET /blogs/site/all?locationId=&skip=0&limit=25 | Sites under data[]. This is where you get the blogId. |
| List posts | GET /blogs/posts/all?locationId=&blogId=&limit=50&offset=0 | Hides DRAFTs unless you add &status=DRAFT. limit max 50 (over = 422). Array comes back under key blogs. Does NOT return rawHTML. |
| Get ONE post | GET /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 post | POST /blogs/posts | Full body (below). 201. New id in the returned post. |
| Update post | PUT /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 authors | GET /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 categories | GET /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.
Extending GHL
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) |
| Scope | Local: only inside that one workflow | Global: every matching event across the location/agency |
| Setup | A "Custom Webhook" step in the Workflow Builder | Configured in the HighLevel Developer Portal app |
| Payload | Fully custom (merge fields) | Standard JSON predefined by GHL |
| Auth/verify | Custom headers / query params you set | GHL signs it with a public key (verify, see below) |
| Use | Mid-workflow data fetch / conditional logic | App 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.
Social Media Posting
Path root:
/social-media-posting/{locationId}/posts. Every write takes the SAME full body shape. There is no partial update.Drafts only by policy (Rule 12: Dr. DNicole reviews before publish). All of
accountIds,userId, andcreatedByare required;createdBymust mirroruserId.The post is nested under
results.post, not at the top level. Parse defensively or you will read an emptyaccountIds/mediaand then send a broken update.Do not PATCH
A partial
PATCHwith{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 GET hands you media as
{url, type, thumbnail}withtype:"image". GHL rejects that on a scheduled write: 422 "Invalid media format type." Rebuild every item as{url, type}with a real MIME, and dropthumbnail.To approve a draft, PUT it with
status:"scheduled"and a futurescheduleDate. The IEXDG helper force-clamps any past or missing date tonow + 24hso 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,
scheduleDatedefaults 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 plannedscheduleDate, 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 returns 200 and soft-deletes (a later GET shows
deleted:true). Media upload: multipartPOST /medias/upload-file?locationId={loc}returns a CDN URL; attach it as a media item on a subsequent PUT. Re-readaccountIdsfrom the envelope before re-PUTting, or the attach fails. Size cap: GHL rejects oversized files with413 FILE_SIZE_EXCEEDED; downscale heroes to roughly 2048px / under a few MB before upload (a licensed Shutterstock "huge" jpg at 30MB+ will 413).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 }The
statusfield on a post moves through:Yes, GHL has a first-comment field
Set
followUpCommentin 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 echoesfollowUpCommentback. It travels per account, so it lands on LinkedIn, Facebook, and Instagram alike.List and filter posts. POST (not GET) with a filter body. Use
statusto surface drafts the dashboards hide.Returns every connected account and group. The
accountIdsyou put on a post are these composite ids, shaped{platformInternalId}_{locationId}_{platformAccountId}{_suffix}. IEXDG live set:DELETE
/accounts/{id}disconnects one.Connecting a new channel is a two step OAuth handshake, one path family per platform (
facebook,instagram,google,linkedin,twitter,tiktok,tiktok-business).Schedule many posts from a spreadsheet: upload, validate, set the target accounts, then finalize.
Categories drive the recurring "category queue" (an evergreen-bucket scheduler). A post carries one
categoryIdand any number oftags.A real
results.postfrom 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.