Skip to content

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)

  1. 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.py and ~/src/altapac/Drive-Trade-Templates/migration/neto_export.py (the API client the scripts import)
  2. Put the API credentials in ~/src/altapac-org/Altapac-ReDev/.env. It needs three variables: NETO_API_URL, NETO_API_USERNAME and NETO_API_KEY. That file is gitignored. Keep it at mode 600 (chmod 600).
  3. Fetch the theme repo so the template check is current: git -C ~/src/altapac-org/Altapac-ReDev fetch.

Steps

  1. 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 IDs
    python3 scripts/pull.py --type 13 # every page of one content type

    Each page lands in pages/<ContentURL>/. Run git status straight 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 example content: re-pull brands before applying description fixes) so your edit’s diff stays clean.

  2. 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 .html file.

  3. 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.

  4. 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.

  5. Push. Stage your edit first (git add pages/<url>), then run python3 scripts/push.py pages/<url> --yes. The script sends only the changed fields, reads the page back and re-pulls it. Success ends with pushed and verified: and a list of field names.

  6. 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.

  7. Re-pull and diff. Run python3 scripts/pull.py <ContentID>, then git 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.

  8. 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 ContentURL moves 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 is pages/_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 as pages/brand/hsp~13981. Always check ContentID in meta.json.
  • Deleting an .html file 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 (with ContentName, ContentType, ParentContentID and Active; the store generates ContentURL), 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 leave Active, OnMenu and OnSiteMap alone 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.py turns 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 keyWhat it isPushed?
Description1.html, Description2.html, Description3.htmlPage body fields (“Content Description 1/2” in the blog guide)Yes
ShortDescription1.htmlShort description 1 (“Subtitle” in the blog guide)Yes
ShortDescription2.html, ShortDescription3.htmlShort descriptions 2 and 3Yes
ContentNamePage name; most templates use it as the H1 unless SEOPageHeading is setYes
SEOPageTitle, SEOMetaDescription, SEOPageHeading, SEOCanonicalURL, SEOMetaKeywordsSEO fieldsYes
ContentURLPage URL (moves the folder on the next pull)Yes
ParentContentID, SortOrder, ContentReference, AuthorPage tree position and detailsYes
Active, OnMenu, OnSiteMapVisibility, as "True" or "False"Yes
BodyTemplate, HeaderTemplate, FooterTemplate, SearchResultsTemplateTemplate names; "" means the theme defaultYes
ExternalSource, ExternalReference1, ExternalReference2Legacy import details (for example Content Page (v4.5)); leave aloneSent if changed
ContentID, ContentTypeIdentityNever
DatePostedDate posted; set it in cPanelNever
DateUpdatedLast update on the store; used for the stale-edit checkNever
pulledWhen you last pulled the page; local onlyNever

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.

InputAfter a cPanel save
Attributes in any orderSorted alphabetically
Block tags on one lineEach 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 rowsIndented with tabs (one tab for <li>)
Lines holding only spacesDropped
End of fieldExactly one newline
Named and numeric entitiesDecoded to characters, except &amp; &lt; &gt; &nbsp;
Raw non-breaking spaceBecomes &nbsp;
&quot;Becomes "
Bare &, <, > in textEncoded
Runs of spaces or tabs in textCollapsed; leading and trailing spaces inside a tag trimmed
<br>, <hr>, <img ...>Self-closed (<br /> followed by a newline)
Boolean attributesExpanded (checked="checked", data-x="")
Single-quoted attributesDouble-quoted
Empty <p><p>&nbsp;</p>
Loose text outside a blockWrapped 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.

TypePagesWhat they are
1175Product category pages
218Info pages (home, about, contact, policies, store finder)
915Forms and thank-you pages
10130Brand pages
1238Service pages
1343Learn pages
162Promotion 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 handling
  • Altapac-Content/scripts/push.py: read-only keys, DateUpdated check, wire() escaping (UpdateContent entity-decodes and turns raw newlines and tabs into spaces), template check against origin/main:src/templates/cms/<name>.template.html, read-back verification
  • Altapac-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 UpdateContent API documentation (field list)