Push CMS content through the API
This page is for staff who edit content pages from the Altapac-Content git repo instead of typing into the cPanel editor. Use it when you are changing many pages at once, when you want a reviewable diff of every change, or when the page HTML is too fiddly for the WYSIWYG.
The loop is: pull → edit → dry run → push → save in cPanel → re-pull → diff → commit. The scripts do the API calls. The cPanel save is still needed, and some fields can only be set in cPanel.
It covers content pages only (categories, info pages, brands, services, Learn, forms, promotions). Product updates have different rules and are not covered here.
Before you start (one-time setup)
- Have these checkouts in your home folder. The scripts look for them at these exact paths:
~/src/altapac-org/Altapac-Content(this repo; run every command from its root)~/src/altapac-org/Altapac-ReDev(the theme; holds the API credentials and is used to check template names)~/src/altapac/wholesale-match/api.pyand~/src/altapac/Drive-Trade-Templates/migration/neto_export.py(the API client the scripts import)
- Put the API credentials in
~/src/altapac-org/Altapac-ReDev/.env. It needs three variables:NETO_API_URL,NETO_API_USERNAMEandNETO_API_KEY. That file is gitignored. Keep it at mode 600 (chmod 600). - Fetch the theme repo so the template check is current:
git -C ~/src/altapac-org/Altapac-ReDev fetch.
Steps
-
Pull the page. From the repo root, pull by Content ID or by content type:
Terminal window python3 scripts/pull.py 15977 15978 # one or more Content IDspython3 scripts/pull.py --type 13 # every page of one content typeEach page lands in
pages/<ContentURL>/. Rungit statusstraight after. If the pull changed files you didn’t touch, someone edited the page in cPanel since the last pull. Commit that re-pull on its own (for examplecontent: re-pull brands before applying description fixes) so your edit’s diff stays clean. -
Edit the files. Change the HTML files and the editable keys in
meta.json(see the field map below). Write HTML in the form the cPanel editor leaves (see Editor rewrites), so the later cPanel save doesn’t change it. To empty a field, delete its.htmlfile. -
Check for emoji. The push script does not check for this. Run it on the page folder and fix anything it prints:
Terminal window python3 -I -c "import sys,pathlib; [print(p.name, f'U+{ord(c):X}') for p in sorted(pathlib.Path(sys.argv[1]).iterdir()) if p.is_file() for c in p.read_text() if ord(c) > 0xFFFF]" pages/<url>No output means the page is clean.
-
Dry run.
python3 scripts/push.py pages/<url>reads the live copy (read-only), compares it with your files and prints a diff for each field that would change. It writes nothing. Read every hunk. -
Push. Stage your edit first (
git add pages/<url>), then runpython3 scripts/push.py pages/<url> --yes. The script sends only the changed fields, reads the page back and re-pulls it. Success ends withpushed and verified:and a list of field names. -
Save the page in cPanel. Open the page’s edit screen in a fresh tab, or reload the tab if it was already open, then save without changing anything. This clears the page cache, and it is when the editor rewrites the HTML. If you don’t have cPanel access, ask the owner to do the save.
-
Re-pull and diff. Run
python3 scripts/pull.py <ContentID>, thengit diff. Any change since step 5 was made by the cPanel save (or by someone else). Whitespace and formatting changes are normal. If the diff undoes your edit, the save came from a stale tab: see the first caution below. -
Commit. Commit the re-pulled files with a
content:message saying what changed and on which pages.
To check the live page before the cPanel save, add ?nc= and any random value to the URL. That skips the page cache. After the save, check the page without it to confirm visitors see the new version.
What breaks
Other things to know:
- Changing
ContentURLmoves the page’s folder. On the next pull the old folder’s files are deleted and the page lands under its new URL. Child pages in subfolders are left alone. - Folder names aren’t always the URL. A page with an empty URL goes in
pages/_id-<ContentID>(the home page ispages/_id-39). If two pages map to one folder (for example a URL with and without a trailing slash), the second gets~<ContentID>added, such aspages/brand/hsp~13981. Always checkContentIDinmeta.json. - Deleting an
.htmlfile empties that field on the store at the next push. A pull also deletes the local file when the field is empty on the store. - New pages can be created through the API’s
AddContent(withContentName,ContentType,ParentContentIDandActive; the store generatesContentURL), but the repo has no script for it. Create them inactive. The owner previews by switching Active on with menu and sitemap off, and turns those on at go-live, so leaveActive,OnMenuandOnSiteMapalone on a new page until the owner says otherwise. - Learn articles need
ShortDescription1(SEO and listing cards draw on it) as plain text: a standalone one- or two-sentence summary, not the article’s first line. The template shows it centred in italics under the title.scripts/md2page.pyturns an approved Markdown draft into a Learn page’s fields in the editor-stable form.
Field map
Each page folder holds meta.json plus one .html file per filled HTML field.
| File or key | What it is | Pushed? |
|---|---|---|
Description1.html, Description2.html, Description3.html | Page body fields (“Content Description 1/2” in the blog guide) | Yes |
ShortDescription1.html | Short description 1 (“Subtitle” in the blog guide) | Yes |
ShortDescription2.html, ShortDescription3.html | Short descriptions 2 and 3 | Yes |
ContentName | Page name; most templates use it as the H1 unless SEOPageHeading is set | Yes |
SEOPageTitle, SEOMetaDescription, SEOPageHeading, SEOCanonicalURL, SEOMetaKeywords | SEO fields | Yes |
ContentURL | Page URL (moves the folder on the next pull) | Yes |
ParentContentID, SortOrder, ContentReference, Author | Page tree position and details | Yes |
Active, OnMenu, OnSiteMap | Visibility, as "True" or "False" | Yes |
BodyTemplate, HeaderTemplate, FooterTemplate, SearchResultsTemplate | Template names; "" means the theme default | Yes |
ExternalSource, ExternalReference1, ExternalReference2 | Legacy import details (for example Content Page (v4.5)); leave alone | Sent if changed |
ContentID, ContentType | Identity | Never |
DatePosted | Date posted; set it in cPanel | Never |
DateUpdated | Last update on the store; used for the stale-edit check | Never |
pulled | When you last pulled the page; local only | Never |
Not in this loop at all: images, Label 1 to 3, related content and anything else not listed above. The scripts don’t fetch or send them, so set them in cPanel. The owner uploads content images there: Alt image 1 at 450 × 450 (the page’s square image for menus, cards and the article’s side image) and Main image at 3:1, for example 1599 × 533 (the optional cover banner on Learn articles). Maropost’s UpdateContent documentation lists Label1 to Label3 and RelatedContents, but the repo hasn’t tested them.
Editor rewrites on save
The cPanel editor (CKEditor 4) rewrites HTML on save by rules Maropost doesn’t document. The repo has recorded these, mostly from a 34-case round-trip test on /testing (content 15976) on 2026-10-21. Every rewrite left the markup equivalent. Push content already in this form so the save changes nothing.
| Input | After a cPanel save |
|---|---|
| Attributes in any order | Sorted alphabetically |
| Block tags on one line | Each block tag on its own line, nested <div>s too |
Sibling blocks such as consecutive <p> | Blank line between them, even inside a <div>, but none before a closing </div> |
<li>, nested lists, table rows | Indented with tabs (one tab for <li>) |
| Lines holding only spaces | Dropped |
| End of field | Exactly one newline |
| Named and numeric entities | Decoded to characters, except & < > |
| Raw non-breaking space | Becomes |
" | Becomes " |
Bare &, <, > in text | Encoded |
| Runs of spaces or tabs in text | Collapsed; leading and trailing spaces inside a tag trimmed |
<br>, <hr>, <img ...> | Self-closed (<br /> followed by a newline) |
| Boolean attributes | Expanded (checked="checked", data-x="") |
| Single-quoted attributes | Double-quoted |
Empty <p> | <p> </p> |
| Loose text outside a block | Wrapped in <p> |
Comments, inline styles, classes, curly quotes, dashes and accented letters come through unchanged.
Content types seen in the repo
For pull.py --type. The numbers are store IDs. Page counts are the repo’s copies as of the 2026-10-20 pull, plus the Learn article added on 2026-10-21.
| Type | Pages | What they are |
|---|---|---|
| 1 | 175 | Product category pages |
| 2 | 18 | Info pages (home, about, contact, policies, store finder) |
| 9 | 15 | Forms and thank-you pages |
| 10 | 130 | Brand pages |
| 12 | 38 | Service pages |
| 13 | 43 | Learn pages |
| 16 | 2 | Promotion pages |
The parts finder content type was left out of that pull and isn’t in the repo.
For developers
This page is based on:
Altapac-Content/CLAUDE.md(“API” and “Pushing content”)Altapac-Content/scripts/pull.py: field lists, folder naming, collision and moved-URL handlingAltapac-Content/scripts/push.py: read-only keys,DateUpdatedcheck,wire()escaping (UpdateContententity-decodes and turns raw newlines and tabs into spaces), template check againstorigin/main:src/templates/cms/<name>.template.html, read-back verificationAltapac-Content/scripts/md2page.py,scripts/audit.py,docs/page-fix-brief.md~/src/altapac/wholesale-match/api.py(host guard) and~/src/altapac/Drive-Trade-Templates/migration/neto_export.py(call(), retries)- Altapac-Content git history: 87e96a0 (stale-save restore), 369acdd (content types pulled)
- Maropost
UpdateContentAPI documentation (field list)