HubSpot facts#
These were verified against real portals while building the first site. They are portable to any HubSpot CMS site. Each one cost time to find.
Pages API#
Create a site page as a draft:
POST /cms/v3/pages/site-pages
{"name": "…", "slug": "…", "templatePath": "<Theme>/templates/base.html", "state": "DRAFT"}
Set the layout with a PATCH. The dnd area's name comes from the template (commonly main_content, not
dnd_area; it is dnd_area in hubspot-site.json):
PATCH /cms/v3/pages/site-pages/{id}
{"layoutSections": {"main_content": {
"type": "cell", "name": "main_content", "w": 12, "x": 0,
"rowMetaData": [{"cssClass": "dnd-section", "styles": {"padding": {"top": …, "right": …, "bottom": …, "left": …}}}],
"rows": [{"0": MODULE_CELL}]
}}}
MODULE_CELL = {"type": "custom_widget", "name": "…", "w": 12, "x": 0,
"params": {"path": "/<Theme>/<module>", "module_id": …, "schema_version": 2, "…fields": "…"}}
Publish: POST …/{id}/schedule {"id": …, "publishDate": "<future>"}. The date must still be in the future
when HubSpot validates it. One hs api round trip can take over a minute on a cold start, so the publisher schedules
150 seconds ahead. The call returns 204 with no body. Poll currentState until the page is live.
| Fact | Detail |
|---|---|
| Nested columns | A cell (w, x, its own rowMetaData and rows) inside a section row. |
| Row keys | The cell's x as a string: {"0": cell w8, "8": cell w4}. An index key renders fine but breaks the editor ("Column key for row does not match X value"). |
| Section background | rowMetaData[i].styles.backgroundImage {imageUrl, backgroundSize: "cover", backgroundPosition: "MIDDLE_CENTER"}. |
| Section padding | styles.breakpointStyles.default.padding. The crawl will also see mobile overrides, so don't confuse the two. |
| Module ids | module_id from meta.json equals moduleId on every portal. |
| Live widget params | Carry only module_id (no path). Another portal accepts them as they are. |
| Head HTML | Sent as headHtml. A PATCH alone doesn't reach the served page; a republish does. |
| Content staging | Staged pages are ordinary page ids on the sandbox domain; their draft can be PATCHed. CONTENT links to draft pages resolve to the live URLs. |
| Landing pages | A separate object (/cms/v3/pages/landing-pages) with the same layoutSections shape. |
Keys and scopes#
- A developer test portal's personal access key has the
contentscope and can create and publish pages. - A personal access key on a production portal only offers CMS Pages read. Writing drafts needs a service key with Content and Files (and Forms) scopes.
- The test portal's key may lack the Forms scope. Form modules then stay unselected on dev, and forms are created and selected by hand.
- HubSpot video player ids belong to one portal. Drop them when copying a layout between portals.
Module fields#
| Restriction | Detail |
|---|---|
name |
Rejected (MODULE_VALIDATION_RESTRICTED_FIELD_NAME). Use full_name. |
label, style |
Rejected. |
items |
Reserved in HubL. Use questions, stats, … |
text fields on the Style tab |
Rejected with a bare internal error. Put anchor ids and classes at the end of the Content tab. |
| Visibility rules | Only evaluate within one tab. A Style-tab rule that points at a Content-tab field never fires. |
file fields |
Hand back a string, not an object: read module.x.url or module.x. |
| Removed choices | A saved page holding a value that has left the choice list fails editor validation. |
Design Manager and File Manager#
hs cms uploadsilently drops.webpfiles. Use jpg, png or svg for theme assets.- A module upload is live on every page using it within a minute. Modules have no draft state.
- The File Manager CDN caches a 404 for about 5 minutes (
s-maxage=300). Probing a URL before uploading poisons it, so probe with a cache-buster query. - Fonts and assets the theme loads from outside its folder must be uploaded to every portal separately.
Menus#
{% menu id=… %}falls back to the menu nameddefaultwhen the id doesn't exist.menu(id)(the function form) does not.- Menus can be created over the API:
POST /content/api/v4/menus
{"name": "footer", "pagesTree": {"children": [
{"label": "…", "url": "…", "linkTarget": null, "isPublished": true, "isDeleted": false, "children": []}
]}}
CLI#
--account <name>with a name that isn't configured silently uses the default account. Runhs account listfirst, every time.- Developer test portals expire after 90 idle days.