Official iHomepage CMS documentation. An open foundation for website content management — a free-form content model, native multilingual support, SEO-friendly by default, template-driven rendering and AI agent integration. Covers getting started, core concepts, design principles, templates, routing, static publishing, admin and API, configuration and a launch checklist.
iHomepage CMS is an open foundation for website content management. It provides only the most basic, stable mechanisms — one content table, two kinds of relationships, one set of template rules — and leaves everything else for you to define, which gives it more freedom than comparable CMS products. Multilingual support, SEO-friendliness and templating are part of its nature rather than plugins, and its consistent, predictable APIs let AI agents connect directly to build and maintain sites for you.
This documentation is written for two audiences: the teams who build and run websites, and the AI agents that do website work on their behalf. After reading it you will know how to set up a site from scratch, how to organize content, how to write templates, which capabilities come out of the box, and the rules for working with this CMS.
iHomepage CMS is a classic website content management system — and an open foundation:
You don't deploy servers, design databases or build an admin panel. You define content, write templates and publish content.
| Feature | Details |
|---|---|
| High freedom | You define content types, the relationships between them and how every page looks; a new kind of content needs no schema change and no plugin |
| Native multilingual | Language prefixes, translation groups, hreflang and language switching are built in and enabled with a single setting |
| SEO-friendly | Canonical URLs, canonical tags, path-based pagination, table-of-contents anchors and static publishing — search-friendly by default |
| Template-driven | Every page is rendered by a Twig template, kept as a file or edited online in the admin console |
| AI & agent ready | A built-in MCP server: agents sign in with OAuth and manage the site in plain language; an inferable content model with templates and content as plain text |
| Batteries included | Users, forms, comments, likes and favorites, email subscriptions, online payments and an admin console are built in |
Compared with a traditional CMS such as WordPress:
| Traditional CMS (e.g. WordPress) | iHomepage CMS | |
|---|---|---|
| New content types | Register a custom post type, usually with code or a plugin | A new taxonomy value plus a template with the same name |
| Relationships between content | Beyond categories and tags, usually plugins or custom fields | Built-in many-to-many associations whose meaning comes from the types at both ends |
| Custom fields | Often a fields plugin | meta_json, read directly in templates |
| Multilingual | Requires a multilingual plugin | Native |
| SEO | Often completed with an SEO plugin | Built in by default |
| Presentation | Customized within a theme framework | Templates are entirely yours, with no theme framework to work around |
| Performance | Often a caching plugin | Built-in static publishing serves pages as static files |
| AI integration | Depends on plugins | Built-in MCP server with OAuth sign-in, and documentation written for agents too |
| Part | Contents |
|---|---|
| Getting Started | Build a working site from zero |
| Core Concepts / Design Principles | Why the CMS works the way it does |
| Content Model / Templates / Routing | The three areas you use every day |
| Multilingual / SEO / Static Publishing / Users & Payments / AI & Agents | Built-in features |
| Admin / API / Configuration Reference | Reference material |
| Security / Responsibilities & Rules / Extending | Rules for working with this CMS |
| Launch Checklist / FAQ / Glossary | Wrapping up and troubleshooting |
Not every website needs the CMS. Decide which kind of site you are building:
| Type | Good for | CMS content database |
|---|---|---|
| Static site | A few fixed pages, rarely updated | Not needed |
| Static pages + standalone app | Online tools, calculators, SaaS front ends | Not needed |
| CMS content site | Articles, products, cases, docs — content that keeps growing | Needed |
| Hybrid site | Your own home page and tools; blog, products and docs run on the CMS | Needed |
Hybrid sites are the most common: pages and apps you build yourself always take priority, and the CMS serves the content pages.
Every website has its own site directory:
your-site/
├── public/ Public files: static pages, CSS, JS, images, fonts, front-end build output
├── templates/ (optional) Twig file templates
└── db/
└── cms.db (optional) The site's CMS database: content, templates, settings, users, forms, orders
public/ are served directly; each URL maps to a file path.templates/ directory or in the database (edited in the admin console). Pick one.cms.db inside db/ is treated as the content database. Give your own business databases any other name and the CMS will never read them.Fill these in under Site Settings in the admin console:
| Setting | Example | Description |
|---|---|---|
site_name | Acme | Site name, appended to page titles |
domain | www.acme.com | Canonical domain |
scheme | https | Protocol |
home_title / home_description | Home page title and description |
See Configuration Reference for every setting.
A header partial, header.html:
<!doctype html>
<html lang="{{ SITE.html_lang|default('en') }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ base.title }}</title>
<meta name="description" content="{{ base.description|e }}">
<link rel="canonical" href="{{ base.url }}">
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
The home page template, home.html:
{{ include('header.html') }}
<main>
<h1>{{ SITE.site_name }}</h1>
{% set latest = list_post({taxonomy: 'article'}, 6) %}
{% for item in latest.list %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% endfor %}
</main>
{{ include('footer.html') }}
The article template, article.html:
{{ include('header.html') }}
<article>
<h1>{{ the.title }}</h1>
<div class="content">{{ the.body|raw }}</div>
</article>
{{ include('footer.html') }}
Create a content item in the admin console:
| Field | Value |
|---|---|
| Title | Hello World |
| Type (taxonomy) | article |
| URL | /articles/hello-world/ |
| Status | Published |
Open https://your-domain/articles/hello-world/ and the CMS renders it with article.html. It also appears in the home page list.
Add a class to your login link:
<a href="#" class="user-panel-login-title">Login</a>
The CMS adds /init.js to every page automatically. When it finds user panel elements on the page it loads the panel, so you don't need to add a script tag yourself. /init.js also handles visit statistics, which appear on the admin dashboard. Both can be adjusted with settings:
| Setting | Values |
|---|---|
cms_js_panel | auto (default, load the panel only when the page has panel elements), on (load on every page), off (never load) |
cms_js_stats | On by default; set to 0 to turn visit statistics off |
Visitors get a sign-in dialog; site administrators can switch to the content admin console after signing in. The first administrator of each site is set up by the platform.
That's it — you have a content site you can keep running. Read Core Concepts and Content Model next.
Visitor requests https://your-domain/some-path
↓
Does the site's public/ contain this file?
├─ Yes: return it directly (static file, fastest)
↓ No
Hand over to the CMS
→ Platform API (e.g. /user/api/login)? Handled by that API
→ Does the site have a content database? No: return 404
→ Is there a redirect for this path? Redirect to its destination
→ Look up content or a template for the path, render and return the page
Three things to remember:
public/ is always served first; the CMS never overrides it.In iHomepage CMS, articles, products, categories, tags, cases, downloads, stores, revisions… are all "content", stored in a single content table, post, and told apart by taxonomy (type).
| Relationship | Cardinality | Stored in | Example |
|---|---|---|---|
| Hierarchy | One-to-one | parent_id | An article belongs to a category; a sub-category belongs to a parent |
| Association | Many-to-many | Relation table post_post | An article has many tags; a product is sold in many stores |
The CMS does exactly the same thing for every content item: it prepares the item itself, its parent, its associations and the list of items under it, and hands them to the template. **Whether an item renders as a detail page or a list page is decided by the template — the CMS never decides.**
Every content item has one canonical URL (url). Visitors, search engines, static publishing and internal links all revolve around it.
Understand these and you can work out the answer to most questions on your own.
Adding a new content type or a new kind of relationship never requires a database schema change: a new type is just a new taxonomy value, and a new relationship is just an association between two types. The structure stays stable, and every upgrade to the foundation benefits your site directly.
The platform provides mechanisms only: one content table, two relationship types, one template lookup rule, one rendering path. "What counts as a category", "what this association means" and "what the page looks like" are all defined by the site. The platform does not — and should not — know your business.
Your site's own files, pages and apps always take priority. The CMS only handles paths your site doesn't cover.
The foundation only takes in general-purpose capabilities. Every capability added to it must be general, and by default must not change the behavior of any existing site. The platform has no per-site switches, exceptions or special cases.
Upgrades don't change the output of existing pages by default. When an old way of doing something is replaced, the old way keeps working and is marked "deprecated"; sites are not forced to change right away.
Rich text is sanitized on save, credentials are never echoed back, anonymous uploads are private by default, and admin permissions are checked on every request. Security lives in the platform, so sites don't have to implement it individually.
taxonomy values are defined freely by each site; the platform presets none. Common conventions:
article / post articles news news blog blog posts
product products service services project projects
case cases document documents download downloads
dir categories tag tags version revisions
A new content type = a new taxonomy name + a template with the same name. Nothing else.
parent_id points to the item one level up and expresses one-to-one membership only:
Use parent_of(the) in templates to get the parent.
post_post(post_id, child_post_id, weight)
post_id is the owner, child_post_id is the member. For example "category → article", "article → tag". Write it the wrong way round and the same data means the opposite.(dir, article) article is in this category (article, tag) the article's tags
(product, service) optional add-on service (store, product) products sold in this store
(guide, download) downloads for this guide (course, teacher) the course's teachers
weight is the item's position within this group. The same article can sit at different positions in different categories.In templates, related(the, 'tag') returns "what this item is associated with", and list_related(id, …) returns "what is listed under this item".
One-to-one uses
parent_id; many-to-many uses associations. Never store the same relationship in both.
dir categories + article items (in categories) + case items (associated with industry)product under dir, tagged with tag, associated with optional service, sold in several storesguide in categories, associated with download attachments, version for historythread belongs to topic, associated with tag| Field | Description |
|---|---|
title | Title |
description | Summary, also used as the page description |
keywords | Keywords |
body | Body HTML |
url | Canonical URL — always set it explicitly |
taxonomy | Content type |
template | Template override; when empty the template named after taxonomy is used |
status | 2 means published; anything else is not visible |
parent_id | Parent item |
pic_url | Cover image |
author | Author |
publish_time / update_time | Publish and update time |
price | Price, in cents (enter 5999 for $59.99). The cart, checkout and orders all use cents |
weight | Global sort weight |
is_top / is_headline / is_featured / is_home | Pinned, headline, featured and home-page flags |
click_count / like_count / fav_count | View, like and favorite counters |
lang / slug | Multilingual: language code and translation group key |
meta_json | Custom fields (JSON) |
Anything the standard fields don't cover — product specs, FAQ lists, price ranges, external links, button labels — goes into meta_json:
{"specs": {"weight": "1.2kg", "size": "30×20cm"}, "faq": [{"q": "Is shipping free?", "a": "Free over $99"}]}
Read it directly in templates, no parsing needed:
{{ the.meta_json.specs.weight }}
{% for item in the.meta_json.faq %}<dt>{{ item.q }}</dt><dd>{{ item.a }}</dd>{% endfor %}
Every field whose name ends in _json is decoded automatically. Empty values decode to an empty array, and invalid JSON is kept as the raw string, so pages never break because of it.
Gallery: the "Gallery" images in the admin content editor (often used as a carousel on the content page) are stored in meta_json.gallery as an array of image URLs; other keys are left untouched:
{% for src in the.meta_json.gallery %}<img src="{{ src }}" alt="">{% endfor %}
Don't change the database schema for a new field. If a field is something every site would need, raise it as described in Extending & Feature Requests.
| status | Meaning |
|---|---|
2 | Published: accessible, listed, statically published |
0 and other values | Draft or offline: 404 on the front end; existing static pages are removed automatically |
-1 | Deleted (recoverable; associations are kept) |
Templates use Twig syntax.
Looked up in this order; the first match wins:
{name} in the site's templates/ directory{name}.html in the site's templates/ directory{name} or {name}.htmlDon't keep the same template in both places: a file template hides the database template with the same name, so edits in the admin console won't take effect. Database templates must use the twig engine.
| Page | Template |
|---|---|
Home / | home |
Search /search | search |
| Content page | The item's template field → otherwise the template named after taxonomy → otherwise post |
| Not found | 404 (optional; plain-text 404 otherwise) |
Option 1: include partials. Each page template includes its own header and footer:
{{ include('header.html') }}
…page content…
{{ include('footer.html') }}
Option 2: base_layout. Create base_layout.html and mark where page templates go with the literal placeholder {{BODY}}:
<!doctype html>
<html lang="{{ SITE.html_lang }}">
<head><title>{{ base.title }}</title></head>
<body>
{{ include('header.html') }}
{{BODY}}
{{ include('footer.html') }}
</body>
</html>
When base_layout.html exists, page templates are wrapped in it automatically — unless the page template is already a complete HTML document.
| Variable | Contents |
|---|---|
SITE | Site settings: site_name, domain, scheme, logo, menu, URL_ENDING, home_title, home_description; multilingual sites also get lang, lang_prefix, html_lang, languages |
base | The current page's title, description, keywords and url (full canonical URL, including the page number on paginated pages) |
the | The current item, with *_json fields decoded |
the.term | Parent item |
the.taglist | All associated items |
the._toc / the._toc_html | Table of contents built from the body's level-2 headings (array / ready-made HTML); anchors are inserted into the body |
the.comments | Approved comments |
LIST | Items listed under the current item, paginated: {list, total} |
TERMS | Filters parsed from query parameters |
GET | The current request's query parameters |
PAGE | Current page number; path-style pagination (/blog/page/2/) is not in the query string, so this is the only source |
| Function | Purpose |
|---|---|
include(name, vars) | Include a partial; the second argument passes extra variables to it |
list_post(where, limit, page, order) | Query content by conditions; returns {list, total} |
list_related(ids, limit, page, order, where) | Items listed under the given items; multiple ids are intersected, grouped form below |
related(item, taxonomy) | Items this item is associated with, optionally filtered by type |
parent_of(item) | Parent item |
translations(item) | This item in every language |
make_paginate(total, page, size) | Pagination HTML; omit page to use the current page |
get_options(name) | Read one site setting |
Examples:
{# Category page: list its articles with pagination #}
{% for item in LIST.list %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% endfor %}
{{ make_paginate(LIST.total) }}
{# Home page: 6 featured cases sorted by weight #}
{% set cases = list_post({taxonomy: 'case', is_featured: 1}, 6, 1, 'weight') %}
{# Detail page: tags and breadcrumb #}
{% for tag in related(the, 'tag') %}<a href="{{ tag.url }}">{{ tag.title }}</a>{% endfor %}
{% set parent = parent_of(the) %}
{% if parent %}<a href="{{ parent.url }}">{{ parent.title }}</a>{% endif %}
{# Multi-filter: items in both category 3 and tag 7 #}
{% set items = list_related([3, 7], 12, 1) %}
{# Grouped filter: Chinese items under area 101 or 102, and under district 205 #}
{% set items = list_related([[101, 102], [205]], 12, 1, [], {taxonomy: 'firm', lang: 'cn'}) %}
ids in list_related comes in two forms: a flat [3, 7] means listed under every one of them; a grouped [[101, 102], [205]] means OR within a group, AND between groups. The two forms can be mixed, and empty groups are ignored. The fifth argument where adds conditions, written the same way as for list_post.
The fourth argument of list_post and list_related controls sorting and accepts four forms:
{{ list_related(8, 10, 1, 'weight') }} {# one field, descending by default #}
{{ list_related(8, 10, 1, ['weight', 'publish_time']) }} {# several fields #}
{{ list_post({taxonomy: 'product'}, 12, 1, {'price': 'ASC'}) }} {# explicit direction #}
{{ list_related(8, 10, 1, [{'weight': 'DESC'}, {'title': 'ASC'}]) }} {# ordered list #}
list_post sorts by publish time descending; list_related by creation order descending. Specify the order explicitly whenever it matters.taxonomy, slug or url. A stale id doesn't raise an error — the list just silently comes back empty.{{ the.body|raw }} for editor-saved body HTML; never add |raw to untrusted data such as visitor input or query parameters, and use |e where needed./css/site.css, not css/site.css, or assets will 404 on nested URLs.base for SEO tags instead of hard-coding titles and descriptions in the header./ (e.g. /app.css, /robots.txt) are served as-is and statically published. You don't need to write a sitemap — the CMS generates one (see "SEO"). In the templates/ directory, only css, js, json, xml, txt and ico files are directly addressable, so layout partials are never exposed on the web.SITE.site_name, SITE.logo, SITE.menu and get_options('contact_info') instead of hard-coding them. Changes made in the admin console or by an agent then apply across the whole site at once.| Request | Result |
|---|---|
| A path with an enabled rule under Redirects in the admin console | Redirect to the destination (takes priority over every rule below, except platform APIs) |
/ | Home page, home template |
/index.html | 301 to / |
/search?keyword=term | Searches titles and summaries of published content, search template |
/uploads/… | Attachments |
/.well-known/… | Domain verification and protocol discovery files, managed under .well-known in the admin console |
/user/api/…, /user/pay/…, /cms/api/…, /mcp | Platform APIs |
/init.js | Page entry script, added to every page by the CMS |
Same path as a template whose name starts with / | That template's output |
Path ending in neither / nor .html | 301 adding the trailing / |
Same path as an item's url | That item, rendered |
Non-canonical paths such as /{taxonomy}/{id} | 301 to the item's canonical URL |
/{list-url}/page/{n}/ | Page n of the list |
| Anything else | 404 |
/mcp,/.well-known/,/init.js,/js/analysis2.jsand the platform API paths above are reserved by the platform. Don't use them for content or templates.
url; every other path that reaches it redirects there with a 301./blog/my-post/ or file style /blog/my-post.html. The URL_ENDING setting determines the ending of URLs generated for items without an explicit url./ (e.g. /blog/) so they can use path-based pagination, which can be statically published..html in a directory segment (e.g. /design/index.html/26.html).When content is reorganized or a URL changes, don't let the old URL return 404 — send it to the new one under Redirects in the admin console.
| Field | Meaning |
|---|---|
| Source | A site path such as /old/page.html; matched exactly by path, without domain or query string. One rule per path |
| Destination | A site path (/new/page/) or a full http(s):// URL |
| Code | 301 (default), 302, 307 or 308 |
| Status | Enabled / disabled; disabled rules have no effect |
| Note | Free text, e.g. why the page moved |
The console also shows each rule's hit count and last hit time, so you can tell whether an old URL still gets traffic.
/ and platform API paths cannot be redirected./old?utm_source=x goes to /new?utm_source=x; it is not appended when the destination already has one.public/ yourself is never deleted; the console reports it as a conflict, and the redirect won't take effect until you remove it./{taxonomy}/{id}) already 301 to its canonical URL automatically; no rule is needed./blog/page/2/. Query-based ?page=2 works too./blog/page/1/ redirects to /blog/ with a 301; out-of-range pages return 404 instead of empty list pages.page_size setting (default 15). make_paginate matches it automatically when you omit the third argument.GET.page|default(1) always evaluates to page 1 on path-style pagination URLs.?dir=3&tag=7), pagination links automatically switch to ?page=N.Multilingual support is optional. It is enabled only on sites that configure languages; single-language sites are unaffected.
{"default": "en", "available": {"en": "English", "cn": "中文", "es": "Español"}, "hreflang": {"cn": "zh-CN"}}
The short form ["en", "cn", "es"] also works; the first entry is the default language. Language codes may contain only lowercase letters, digits and -.
The same content has exactly the same URI in every language, differing only by a language prefix. The default language has no prefix.
/guides/getting-started/ English (default)
/cn/guides/getting-started/ Chinese
/es/guides/getting-started/ Spanish
This is what makes multilingual work: language switching and hreflang are derived directly from this rule, without any lookups.
lang: the item's language codeslug: translation group key. Items with the same type and slug but different lang are translations of each otherWhen an item is rendered, the page language comes from the item's own lang, not from the URL prefix. <html lang>, hreflang, the language switcher and SITE.lang_prefix are all derived from it, so a wrong lang breaks all of them at once.
On a multilingual site, every item that has a url and renders as a page must satisfy:
lang is required and must be one of the codes in available. Write the default language explicitly too (e.g. en); do not leave it empty. An empty lang still renders in the default language, but lists filtered by language, such as list_post({lang: SITE.lang}), will leave it out.lang matches the URL prefix. URLs of non-default languages start with /{lang}/; URLs of the default language have no prefix. If lang is empty or the default language while the URL sits under /cn/, the page renders as the default language and the switcher and hreflang produce broken URLs like /cn/cn/....taxonomy, the same slug, and the same URL once the language prefix is removed.** A slug is unique within one taxonomy and one lang.Items without a url that only serve as filters or data are exempt from rule 2, but should still carry a lang so they can be selected by language.
On single-language sites (no languages configured), leave lang and slug empty.
When you later turn on multilingual support for a single-language site, first set lang on existing content to the default language code, then publish the other languages.
<html lang="{{ SITE.html_lang }}">
{# hreflang #}
{% for t in translations(the) %}
<link rel="alternate" hreflang="{{ t.hreflang }}" href="{{ t.url }}">
{% endfor %}
{# Language switcher #}
{% for l in SITE.languages %}
{% if l.current %}<strong>{{ l.label }}</strong>{% else %}<a href="{{ l.prefix }}/">{{ l.label }}</a>{% endif %}
{% endfor %}
{# List content in the current language #}
{{ list_post({taxonomy: 'guide', lang: SITE.lang}, 10) }}
{# Internal links keep the language prefix #}
<a href="{{ SITE.lang_prefix }}/about/">About</a>
On single-language sites SITE.lang and SITE.lang_prefix are empty and translations() returns an empty array, so one template works for both single- and multi-language sites.
iHomepage CMS treats search-engine friendliness as default behavior, not a plugin you install.
| Capability | Details |
|---|---|
| Title, description, keywords | Every page gets base.title / base.description / base.keywords; content page titles are "Title | Site name" |
| Canonical | base.url is the full canonical URL; paginated pages include the page number so page 2 onwards isn't treated as a duplicate |
| One URL per page | Non-canonical URLs 301 to the canonical one; missing trailing / is added with a 301; /index.html 301s to / |
| Path-based pagination | /blog/page/2/ is crawlable and statically published |
| No empty pages | Out-of-range pages return 404, so there are no endless empty list pages |
| Multilingual | hreflang, <html lang>, the language-prefix convention |
| Table of contents | Level-2 headings in the body produce a table of contents with anchors (the._toc_html) |
| Static publishing | Pages are pre-generated as static files for fast responses |
| Sitemap | /sitemap.xml is generated automatically and refreshed daily; see below |
| 404 log | Missing URLs are logged and visible in the admin console |
| Attachments | Images and other attachments get stable /uploads/ URLs |
base.title, base.description and base.url in the header — don't hard-code them.title and description, and an explicit, short, stable url.<h1> per page (usually the title, output by the template).alt text to images./robots.txt, and include Sitemap: https://your-domain/sitemap.xml in it.The CMS generates sitemap files from your content — no plugin, no manual upkeep:
/sitemap.xml is an index pointing to /sitemap_1.xml, /sitemap_2.xml, … with at most 50,000 URLs per file.<lastmod> taken from the item's update time./sitemap.xml until the first generation has run./sitemap.xml or /sitemap_{number}.xml: these file names belong to the CMS. Files with these names written by site scripts, templates or uploads are overwritten by the CMS, and templates with these names have no effect./sitemap_brands.xml) and submit it yourself (add a Sitemap: line to robots.txt, or submit it in the search engine's webmaster tools). The CMS does not manage these files and does not link them from /sitemap.xml.The CMS pre-generates pages as static files in the site's public/, so the web server returns them directly without touching the database or rendering templates.
static_publish to yes (off when unset).site_blocked is yes)./ (CSS, JS, robots.txt, …)Not included: URLs with query strings (search, multi-filter combinations). They are always rendered dynamically.
These capabilities are provided by the platform. Call them directly — don't build your own.
the.comments after approval/user/api/form_submit accepts forms with any fields (contact, inquiry, registration…):
contact_info.email) gets a notification emailform_receipt). Receipts are sent through this site's sendmail_smtp when configured, otherwise through the platform mailboxjpg, jpeg, png, gif, webp and pdf are accepted; svg is notYou can take orders without any payment channel, which suits sites that confirm orders manually:
/user/api/order_submit with the line items (title, unit price, quantity, optionally a content id) plus contact and shipping detailsform_receipt setting as forms)Line items are a snapshot taken at checkout (title, unit price, quantity), so renaming, repricing or removing a product later never changes past orders. The content id on a line item is only a reference for reporting how much of a product has sold, and may be absent. When a content id is given, the unit price comes from the server's post.price; if it differs from the price the browser sent, the server price wins and the difference is recorded in the order remark.
Set order_auto_pay to yes to send customers straight into payment after checkout (that branch is not open yet). Leaving it unset, or no, keeps the pending-order flow above and never redirects to payment.
Built on Stripe Checkout (amounts are in cents as well):
/user/pay/order_create with the items (content ids and quantities)price — client-supplied prices are never accepted/user/pay/stripe_webhook, the order becomes paid and records the actual amount, tax and shipping detailsFill in your keys in the stripe_config setting and register the webhook URL https://your-domain/user/pay/stripe_webhook in Stripe. Tax is calculated by Stripe Tax from the shipping address; enable it in your Stripe dashboard.
iHomepage CMS is designed with AI agents in mind: a simple structure, deterministic behavior and consistent APIs, so agents never have to guess.
code; list endpoints share one set of pagination, sorting and filter parameters.| Task | How |
|---|---|
| Change site settings (phone number, logo, menu) | update_settings, after find_text checks whether the text is also hard-coded in a template |
| Add sections and pages | create_category_with_items creates a category and its items in one step |
| Write, update and translate content | create_content, update_content, translate_content (URL and language follow the multilingual rules) |
| Edit templates | update_template, with a Twig syntax check before saving |
| Maintain SEO | Titles, descriptions, URLs and translations through the content tools |
| Read public content | /user/api/post_list and /user/api/post_get, no sign-in required |
Every site has a built-in MCP server (Model Context Protocol, the connection standard supported by Claude, ChatGPT, Cursor and other major agents). There are no keys to copy:
https://your-domain/mcp| Permission | Lets the agent |
|---|---|
| Read (required) | View content, templates and site settings |
| Manage content | Create, edit and delete content and categories, and create translations |
| Change settings | Site name, logo, contact details, menu |
| Edit templates | Change database templates, syntax-checked before saving |
| Regenerate pages | Regenerate static pages by URL |
Changes made by an agent go live immediately and behave exactly like an administrator saving in the console: the same sanitizing, relationship syncing and static publishing. The authorizing account's admin status is re-checked on every call. You can review and revoke authorizations at any time under Agent access, and revocation takes effect immediately. Credentials such as SMS, email and payment settings are never visible to agents.
Permissions: only the site's administrators can enter. Admin status is checked on every request, so revoking it takes effect immediately; an administrator signed in on site A can only manage site A. The first administrator is set up by the platform; after that, administrators can grant or revoke admin status for other users in the console.
| Module | What you can do |
|---|---|
| Dashboard | Last 30 days of page views, visitors, new users, new content and crawler visits |
| Content | Create, edit, delete (recoverable), set hierarchy, associations and their order |
| Templates | Edit Twig templates online |
| Site Settings | Basic info, SEO, contact details and all other settings |
| Attachments | Upload, edit details, delete |
| Comments | Approve, edit |
| Forms | View, process, delete |
| Orders | View, update status |
| Users | Create, edit, deactivate, grant admin |
| 404 Log | Missing URLs and their referrers |
| Page Refresh | Regenerate static pages by URL |
| Data Structure | Check and upgrade the site database structure |
| Agent Access | Connection URL, generate authorization metadata, review and revoke authorized apps |
| .well-known | Manage domain verification and protocol discovery files (JSON or plain text) |
| Redirects | Send old URLs to new ones: create, edit, enable/disable, delete, see hit counts |
https://your-domain/user/api/login{"code": 200, "data": { }}
{"code": 401, "error": "login_required", "message": "Login Required"}
The HTTP status code matches code. On failure:
error is a stable error code. Use it to decide what went wrong and how to tell the user (for example, translated into your site's language);message is a default English description. You can show it as is, but its wording may change, so don't branch on it.Every failure carries error. Where there is no specific code, a generic one follows the status code: bad_request (400), unauthorized (401), forbidden (403), not_found (404), not_allowed (405), payload_too_large (413), unsupported_media_type (415), too_many_requests (429), server_error (500 etc.), service_unavailable (503).
Error codes of the user API /user/api/*:
| Group | Codes |
|---|---|
| Sign-in state | login_required not signed in, session_expired, too_many_requests |
| Account | invalid_credentials wrong account or password, account_disabled, account_create_failed, user_not_found, email_taken, mobile_taken, userid_taken, old_password_incorrect, password_too_short |
| Verification | verification_code_required code missing, sms_code_invalid, email_code_invalid, invalid_email, unsupported_area_code phone area code not supported |
| Delivery | sms_not_configured, sms_send_failed, email_not_configured, email_send_failed |
| Google sign-in | google_not_configured, google_unavailable, google_credential_invalid, google_email_missing, google_email_unverified |
| Content and reactions | post_not_found, comment_empty |
| Forms, orders and payment | form_unavailable, order_unavailable, order_not_found, order_create_failed, invalid_order_items, product_unavailable, payment_not_configured, payment_failed |
| Uploads | upload_failed, file_too_large, file_type_not_allowed |
| Parameters | missing_parameter, invalid_parameter (see message for which one) |
/user/api/*| Endpoint | Sign-in | Description |
|---|---|---|
session | No | Current sign-in state and user info |
join | No | Sign up |
login / logout | No | Sign in / sign out |
login_google | No | Google sign-in |
get_profile / edit_profile | Yes | Read / update profile |
edit_passwd | Yes | Change password |
password_reset | No | Reset password with an email code |
send_email_code / send_sms_code | No | Send a verification code |
update_email / update_mobile | Yes | Change email / mobile |
comments_submit | Yes | Submit a comment |
like / unlike / favorite / unfavorite | Yes | Likes and favorites |
like_status | No | Batch like/favorite state and counts |
subscribe | No | Email subscription |
form_submit | No | Submit a form |
order_submit | Yes | Place an order without online payment (stored as pending) |
put_file | No | Upload a file |
post_list / post_get | No | List and detail of published content |
orders_list / orders_get | Yes | My orders |
Content list example:
GET /user/api/post_list?where[taxonomy]=product&keyword=chair&page=1&page_size=20
post_list parameters:
| Parameter | Description |
|---|---|
where | Exact match on taxonomy, parent_id, lang, slug and the featured flags; other fields are ignored |
keyword | Matches title and description |
related | Optional filter by relations, in the grouped form of the template function list_related: [[101, 102], [205]] means listed under 101 or 102, and under 205. Empty groups are ignored; at most 10 groups and 100 ids |
order | Optional, written like the sort argument of the template functions; when given, id descending is appended as a tie-breaker |
page / page_size | Pagination, page_size at most 100 |
where, order and related can each be sent in three equivalent ways: in a JSON body; with brackets in the query string (where[taxonomy]=firm); or as a JSON string in the query string (where={"taxonomy":"firm"}). Invalid JSON returns 400 instead of silently returning unfiltered content.
With related, the default order is the same as the template function list_related, so a list page can use it for checkbox filters with Ajax refresh and stay in the same order as the first render:
POST /user/api/post_list
{"where": {"taxonomy": "firm", "lang": "cn"}, "related": [[101, 102], [205]], "page": 1, "page_size": 15}
{"code": 200, "data": {"list": [], "total": 0, "page": 1, "page_size": 20}}
/user/pay/*| Endpoint | Auth | Description |
|---|---|---|
order_create | Signed in | Create an order and return the Stripe Checkout URL checkout_url |
stripe_webhook | Stripe signature | Stripe callback |
/cms/api/*Requires site administrator permissions and powers the admin console. Covers content, templates, settings, attachments, comments, forms, orders, users, redirects, statistics and static page refresh. List endpoints share pagination, sorting and filter parameters:
{"_page": 1, "_page_size": 20, "_order": {"id": "DESC"}, "_filter": {"status": 2, "title[~]": "keyword"}}
_page_size is capped at 200!, >, <, >=, <=, ~ (contains), !~, <> (range)Site settings are maintained under Site Settings in the admin console. JSON settings take JSON text.
| Setting | Description | Default |
|---|---|---|
site_name | Site name | |
domain | Canonical domain | The requested domain |
scheme | Protocol | https |
logo | Logo URL | |
menu | Menu data for templates | |
home_title / home_description | Home page title and description | |
URL_ENDING | Ending of generated URLs, / or .html | / |
page_size | Items per list page | 15 |
site_blocked | yes blocks the whole site: every URL returns only site_message (maintenance / closed mode) | no |
site_message | Notice shown while blocked; only takes effect when site_blocked is yes. If left empty, a generic "temporarily unavailable" line is shown |
| Setting | Description | Default |
|---|---|---|
languages | Multilingual configuration (JSON), see Multilingual | Off |
static_publish | yes enables static publishing | Off |
join_need_check | 1 requires approval for new sign-ups | Active immediately |
| Setting | Format | Description |
|---|---|---|
contact_info | {"email": "...", "name": "...", "mobile": "...", "address": "..."} | Contact details; email receives form notifications |
sendmail_smtp | {"host","port","secure","username","password","email","name"} | The site's own mail account: verification codes, form receipts |
form_receipt | {"enabled": true, "content": "..."} | Send a receipt to form submitters |
sms_config | {"apiUrl","apiKey","tplId"} | SMS verification codes. Leave empty to use the shared platform gateway; only set this if you bring your own |
user_agreement_url | Site-relative path | Link behind "User agreement" in the sign-up box; defaults to /user-agreement/ |
privacy_policy_url | Site-relative path | Link behind "Privacy policy" in the sign-up box; defaults to /privacy-policy/ |
panel_hide_register | yes / no | yes hides the "create an account" link inside the sign-in box. For sites where visitors must complete your own flow first — registration is then only reachable from the register button on your pages. Allowed by default |
| Setting | Format | Description |
|---|---|---|
order_auto_pay | yes / no | yes sends customers into payment right after checkout; unset or no leaves orders pending for manual review and only sends the notification emails |
stripe_config | {"secret_key","webhook_secret","success_url","cancel_url","tax_countries"} | Stripe payments; redirect URLs may contain the {ORDER_NO} placeholder |
social_login | JSON | Social sign-in settings |
Credential settings (SMTP, SMS, Stripe, social sign-in) are never shown in plain text after saving.
The platform has these protections built in; sites must follow the matching rules.
| Platform protection | What you need to do |
|---|---|
Body HTML is sanitized against an allow-list on save: script, style, iframe, form and similar are removed, event attributes are stripped | Put interactivity and styles in templates, not in content bodies. Bodies hook into template styles via class (kept on all formatting tags); use native <details>/<summary> for collapsible content |
| Templates don't auto-escape | Never use |raw on untrusted data |
| Credentials live only in the site's settings and are never echoed back | Never put keys in templates, front-end code or public files |
| Admin permissions are checked on every request; sites are isolated from each other | Revoke admin rights that are no longer needed |
| Uploads use an extension allow-list, reject SVG, and anonymous uploads are private by default | Don't build separate upload endpoints |
| Payment amounts are calculated on the server; webhooks verify signatures | Never calculate or send prices from the front end |
| Sign-in, sign-up, verification code and form endpoints are rate-limited | Don't build separate sign-in or form endpoints |
| Deletion is soft; associations are kept | To restore, change the status back |
| Static publishing only writes safe file types and paths | Don't edit generated static pages by hand |
The CMS foundation and the websites built on it evolve independently. Clear boundaries let the foundation upgrade safely and let your site develop freely.
| Area | Owner |
|---|---|
| The content database structure (tables, fields, indexes) | Platform |
| Page rendering, routing rules, template functions | Platform |
| Platform APIs (users, payments, admin) | Platform |
| Static publishing and the files it generates | Platform |
| The data in the content database: content, templates, settings, users, forms, orders | Site |
Files the site placed in its public/ | Site |
The site's templates/ directory | Site |
| The site's own databases, scripts and standalone apps | Site |
In one sentence: the database structure belongs to the platform; the data belongs to the site.
Free to do:
taxonomy + parent_id + associations + meta_json)public/Don't:
|raw on untrusted dataparent_id and associationsMost needs don't require platform changes:
| Need | Approach |
|---|---|
| A new field | meta_json |
| A new content type | New taxonomy + template with the same name |
| A new relationship | New type combination + association |
| A special layout for one page | Set a dedicated template in the item's template field |
| Complex lists | Combine conditions and sorting in list_post / list_related |
| Standalone functionality | A standalone app in the site's public/ |
The platform doesn't change for a single site. A request can become part of the platform only if all of these are true:
Include:
The platform never adds a switch just for one site.
Pages
base<html lang> is correct; multilingual sites have complete hreflang/, pagination works, out-of-range pages return 404Content
urlmeta_jsonFeatures & security
|raw on untrusted dataWith static publishing, only the home page updates automatically after a template change; other pages need a full rebuild. If it still doesn't change, check whether a file template with the same name in templates/ is hiding the database template.
Check in order: is the content published; does the association exist in the right direction (owner first); is the template using a stale hard-coded id; is a sort field misspelled.
You requested a URL that isn't its canonical URL. This prevents duplicate indexing and is expected.
Use the association's weight: list_related(category_id, 10, 1, 'weight').
Use meta_json. If every site needs it, raise it under Extending & Feature Requests.
Check that the template name matches the item's template or taxonomy (the .html suffix is optional), and that database templates use the twig engine.
{{ or {%These are Twig syntax in templates. To output them literally, wrap them in {% verbatim %}…{% endverbatim %}. Content bodies are not affected.
Expected behavior; nothing to fix.
That language has no translation of the page yet. Add it following the URI convention.
| Term | Meaning |
|---|---|
| Content (post) | Every data unit in the CMS: articles, products, categories, tags… |
| taxonomy | Content type, defined freely by each site |
| Hierarchy (parent_id) | One-to-one membership |
| Association (post_post) | Many-to-many relationship with an owner, a member and a sort weight |
| Owner / member | The two ends of an association; in "category → article" the category is the owner |
| meta_json | Custom fields on content |
| Canonical URL | The item's url, its single public address |
| Template | A Twig file or database template that decides how content is displayed |
| base_layout | Optional site-wide page shell |
| Static publishing | Pre-generating pages as static files |
| Full rebuild | Regenerating every static page of the site |
| Site settings (options) | Site-level configuration |
This page is itself built with iHomepage CMS: it is document content in the official site's content database, available in English and Chinese at the same URI (/documents/ and /cn/documents/), rendered by the template document.html, with the table of contents taken from the._toc, and statically published.
Every iHomepage CMS template is built on the content model described here — pick one for your industry and start publishing.
Browse templates