The Content API is an authenticated JSON API for reading and editing a Real Geeks website’s blog posts, categories, content pages, area pages, home page, navigation, sidebars, footers, design, site settings and uploaded files. It is intended for third-party integrations and for command-line tools driven by people or AI agents, such as geekcli, the open-source Real Geeks command line, which covers every endpoint documented here.
The API is served on the site’s own domain under /api/v3/. Every request in these docs is relative to https://<site-domain>.
Documentation sections:
- Blog Posts & Categories
- Content Pages, Area Pages & Home Page
- Property Searches
- Navigation, Sidebars, Footers & Featured Pages
- Design, Site Settings & Files
- Changelog
Availability
The Content API is in beta and is on for every Real Geeks website. There is nothing to request and nothing for Real Geeks to switch on: a site owner creates keys under Admin → API keys, or approves a login from the command line, as described below. The AI assistant connection is available on every site in the same way.
Authentication
Every request needs an API key in the Authorization header:
Authorization: Bearer rg_live_...
Keys belong to one site and cannot reach any other. A site owner creates them under Admin → API keys (/admin/api_keys/apikey/). The plaintext key is shown once at creation. Every key expires: the owner picks an expiry of at most six months when creating it (CLI logins always get six months), and keys can be revoked at any time. A key that goes unused for 90 days also stops working (401 token_expired, with a message saying it expired from inactivity); the API Keys list shows it as “expired (inactive)”. A key created by approving a command-line login or an AI assistant emails the person who approved it, with the scopes granted and a link to revoke it. From a terminal, geekcli auth login --site www.yoursite.com gets and stores a key through the browser approval described below, so nobody copies a key by hand.
Scopes
A key has scopes. A write scope implies the matching read scope; grant only :read scopes for a read-only key or connection. Keys are 256-bit random values, shown once and stored only as a hash.
| Area | Scopes | Covers |
|---|---|---|
| Blog | blog:read, blog:write |
/blog/posts/, /blog/categories/ |
| Content pages | pages:read, pages:write |
/content/pages/, /content/agent-pages/, /content/market-report-pages/, /content/banners/ |
| Area pages | area_pages:read, area_pages:write |
/content/area-pages/ |
| Home page | home_page:read, home_page:write |
/content/home-page/ |
| Navigation | navigation:read, navigation:write |
/content/navigation-bars/ |
| Sidebars | sidebars:read, sidebars:write |
/content/sidebars/ |
| Footers | footers:read, footers:write |
/content/footers/ |
| Site settings | settings:read, settings:write |
/settings/ |
| Design | design:read, design:write |
/design/ |
| Files | files:read, files:write |
/files/ |
GET needs the read scope; POST/PUT/PATCH/DELETE need the write scope. GET /api/v3/me/ and GET /api/v3/content/templates/ accept any valid key.
Auth failures return 401 with a WWW-Authenticate: Bearer header and one of the codes missing_token, invalid_request, invalid_token, token_revoked, token_expired. A key without the needed scope gets 403 insufficient_scope.
CLI login (browser approval)
A command-line tool can get a key without holding any Real Geeks credentials, by asking the site owner to approve it in the browser. The site’s normal admin login (including 2FA) runs on the way; the tool never sees the owner’s password.
- The tool generates a random
code_verifier(43–128 URL-safe characters), a randomstate, opens a listener on a loopback port, and registers the request:POST/api/v3/auth/cli/start/{"name": "geekcli on my-laptop", "scopes": ["blog:write", "pages:write"], "port": 54321, "state": "<random>", "code_challenge": "<base64url(sha256(code_verifier))>"}
201returns{"request_id": "...", "authorize_url": "https://<site>/admin/api_keys/apikey/authorize-cli/<id>/", "expires_in": 600}. - The tool opens
authorize_urlin the browser. The owner signs in if needed and sees the key name, scopes and loopback port, with Approve / Deny. Approve redirects the browser tohttp://127.0.0.1:<port>/callback?code=<one-time code>&state=<state>; Deny sendserror=access_denied&state=<state>. Only the site owner or a Real Geeks superuser can approve. - Within 60 seconds the tool redeems the code:
POST
/api/v3/auth/cli/token/{"code": "<code>", "code_verifier": "<code_verifier>"}
201returns{"api_key": "rg_live_...", "key": {...}, "site": {...}}. The code is single use, and a wrong verifier burns it. Keys created this way show “CLI login” in the admin. Both endpoints are rate limited per IP.
AI assistants (MCP)
Each site can also be connected to an AI assistant (Claude on claude.ai, Claude Desktop and Claude Code, ChatGPT, or any client that speaks the Model Context Protocol) at:
https://<site-domain>/model_contextAdd that URL as a custom connector in the assistant. It is available on every Real Geeks website, like the API itself.
- Tools. Every
/api/v3/resource on these pages is available as a tool, and each tool call runs the same endpoint, so validation, sanitizing, rate limits, history and warnings are identical. Child lists (sidebar items, featured page tiles, navigation links) are written as a whole list on the parent. Two tools have no endpoint:search_fields, the site’s search field catalog, andfiles_request_upload(below). An assistant only sees the tools its granted scopes allow;site_info,page_templates_listandsearch_fieldsare visible to every connection. - Connecting is a standard OAuth 2.1 flow with PKCE and dynamic client registration (discovery documents under
/.well-known/). The approval page is in the site admin, so the site’s normal admin login runs first; only the site owner or a Real Geeks superuser can approve, choosing which scopes to grant, just like the CLI login. The page shows the host the approval is sent to, and warns when it is not a recognised AI application (Claude, ChatGPT, Cursor, Meta AI) or the person’s own computer, because any client can register under any name. - The access token is an ordinary API key (shown as “Model Context Protocol login”): it is listed under API Keys in the admin, can be revoked there, and expires after six months, when the assistant asks to reconnect. Connected assistants are listed under OAuth clients.
- Blog posts created with
blog_posts_createare saved as drafts unless the call sendsstatus: "published". The REST API’s own default is unchanged. - Transport: stateless streamable HTTP. Every request is a POST answered with plain JSON; there is no SSE stream and no session id.
- Files. A tool call cannot carry file bytes, so
files_uploadtakes acontent_urlthe server downloads itself, from an allow-list of AI platform file hosts (see Files). ChatGPT fills this in for images attached to the chat and images it generates. For files an assistant has locally (Claude’s code execution, Claude Code),files_request_uploadreturns a single-use upload URL, valid for 15 minutes, to POST the file to. On claude.ai, add the site’s domain under Settings → Capabilities → “Additional allowed domains” so the sandbox can reach it.
Conventions
JSON in, JSON out. Write requests must send Content-Type: application/json and a JSON object body (max 2 MB). Responses are application/json.
Errors always use one envelope:
{"error": { "code": "validation_error", "message": "One or more fields are invalid.", "fields": { "slug": [ "Post with this slug exists."]}}}
Non-field errors appear under "__all__". The error codes, by HTTP status:
| Status | Codes |
|---|---|
| 400 | invalid_json, empty_body |
| 401 | missing_token, invalid_request, invalid_token, token_revoked, token_expired |
| 403 | insufficient_scope |
| 404 | not_found |
| 405 | method_not_allowed |
| 409 | conflict — also what two simultaneous writes of the same unique value (a slug, a sidebar name) get; retry |
| 413 | payload_too_large |
| 415 | unsupported_media_type |
| 422 | validation_error, unknown_field |
| 429 | rate_limited |
| 500 | internal_error, files_unavailable |
| 502 | crm_unavailable, design_catalogue_unavailable |
| 503 | file_history_unavailable |
Warnings: a successful write may carry "warnings": [...] next to the resource’s fields, for things that were saved but probably are not what you meant: search criteria values the site does not know, area pages whose search does not filter, and HTML the sanitizer changed. Each warning has a code, the request field and a message. No warnings key means none.
Unknown or read-only fields in a write body are rejected with 422 unknown_field rather than ignored.
PATCH is partial: only the fields you send change. PUT is a full replace: omitted optional fields reset to their defaults.
Creates return 201 with a Location header. Deletes return 204.
Lists of posts, categories and pages are paginated with ?page= and ?page_size= (default 25, max 100); the short lists (navigation bars, sidebars, footers, featured page groups) come whole as {"results": [...]}:
{"results": [...], "pagination": { "page": 1, "page_size": 25, "total": 137, "total_pages": 6, "next": "/api/v3/blog/posts/?page=2", "previous": null}}
?ordering=field or ?ordering=-field sorts (allowed fields are listed per resource).
Datetimes are ISO-8601 with an offset in the site’s timezone, e.g. 2026-09-02T09:30:00-05:00. On input you may send an offset or Z (converted), a naive datetime (taken as site time) or a bare date (midnight).
URLs: every resource includes path (site-relative) and url (absolute).
HTML fields (body, content) are sanitized on write with an allow-list: paragraphs, headings, lists, links, images (with srcset), tables, div/span, figure, blockquote, pre/code, sectioning tags (section, article, header, footer, nav, aside, main, address), details/summary, dl, time, small, mark, abbr, cite, and iframe embeds from YouTube or Vimeo over https. Scripts, event handlers, javascript: links, forms and their controls, video/audio, data-* attributes and HTML comments are removed. Only fields present in the request are sanitized, so a PATCH that does not send content leaves existing markup untouched. In blog bodies the <!--read more--> comment is preserved; it separates the summary from the rest of the post.
CSS: <style> blocks are kept as written, media queries, custom properties and @font-face included, and so is any inline style attribute. The CSS applies to the whole page, not only to the content, so give your class names a prefix of your own. A block or attribute is dropped whole if it contains javascript:, expression(, behavior:, -moz-binding, a url(), @import or image-set() URL that is not allowed (see below), or (outside comments) text that looks like an HTML tag, so percent-encode an SVG data: URL (%3Csvg ...). A URL in CSS may be relative, an image or font data: URL, or http(s) on the site’s own domain, a Real Geeks file or CDN host (u.realgeeks.media, t.realgeeks.media, cdn.realgeeks.com and similar) or Google Fonts. Anything else, such as an image hot-linked from another site, drops the block or attribute with a warning naming the host (<style> (url() to example.com, which is not this site, Real Geeks or Google Fonts)): upload the file with POST /files/upload/ and use the URL it returns. A <style> keeps only its media attribute. In a style attribute an SVG data: URL is not accepted; put it in a <style> block. Don’t load web fonts from content: set the site’s fonts with the HEADING_FONT and BODY_FONT settings (see Fonts).
Structured data: <script type="application/ld+json"> is kept when its body is a JSON object or array. It is re-serialized, so it reads back compacted, with non-ASCII characters and <, >, & written as \uXXXX escapes. Any other <script> is removed. The microdata attributes itemscope, itemprop, itemtype, itemid and itemref are kept, as is <meta itemprop="..." content="...">. So are role and aria-*.
Inline SVG: <svg> is kept with shapes, paths, text, gradients, clip paths, masks and <use> pointing at an #id in the page. Scripts, <style>, <foreignObject>, <image>, <a>, event handlers and external references (an href or url() to anything but an #id) are removed from it. Attribute names read back lower-cased (viewbox); browsers treat them the same.
The write still succeeds when something is removed, and the response carries a sanitized warning for each HTML field that lost something (pages, area and agent pages, posts, the home and blog home pages, footers, sidebar html items and rich_text template areas):
{"id": 12, "content": "<p>Hi</p>", "warnings": [{ "code": "sanitized", "field": "content", "removed": ["<script>", "[onclick]"], "message": "The HTML sanitizer removed <script>, [onclick] from content; the rest was saved"}]}
removed names each kind of thing once (at most 20): <tag> for a removed tag (<script>, <video>, <button> and the like go with their content; others such as <form> or <font> are unwrapped and their text kept), <tag> in <svg> for one removed from an SVG, [attr] for a dropped attribute ([onclick], [data-id], an [href] with a disallowed scheme), <style> (url()) or style (javascript:) for a style block or attribute and why, <script type="application/ld+json"> (not valid JSON), and <!-- comment -->. Disallowed iframes and images without an http(s) src say so. The field is the request field: content, body, items[1].html in a sidebar’s item list, html on a single item, or extra_content.<area>.
Scripts: JavaScript is never saved through the API, on purpose. A key is often held by a tool or an AI assistant, and an assistant that could add <script> to a site could be led into it by text it reads along the way (prompt injection), putting code in front of every visitor. The site’s admin is different: a person is signed in and can see what they paste. To add a script (a chat widget, a tracking snippet, an embed that needs JavaScript), a site admin opens the item in the admin, clicks the code button (<>) in the editor’s toolbar and pastes it into the HTML. The sanitized warning for a removed <script> says this in its message and carries admin_url, the full link to the admin form for the content the request wrote. The admin forms are:
| Content | Admin form |
|---|---|
| Page | /admin/content/contentpage/<id>/change/ |
| Area page | /admin/content/areapage/<id>/change/ |
| Agent page | /admin/content/agentlandingpage/<id>/change/ |
| Market report page | /admin/content/marketreportpage/<id>/change/ |
| Home page | /admin/content/homepage/ |
| Blog post | /admin/blog/post/<id>/change/ |
| Footer | /admin/content/footer/<id>/change/ |
| Sidebar | /admin/content/sidebar/<id>/change/ |
A tool should pass that link and the snippet on to the person instead of retrying. A script saved in the admin stays put as long as later API writes don’t send that field (a PATCH sanitizes only the fields in the request); a write that does send it removes the script again.
Characters: the site databases store text as Latin-1 (Windows-1252), so accented letters, curly quotes, dashes, € and » are fine but arrows, CJK text and emoji are not. Any field containing such a character is rejected with a 422 naming the characters, rather than being saved as ?. Control characters are rejected the same way. HTML entities are decoded before the check, so → is rejected too.
Rate limits: 600 requests per hour per key, and 10 failed authentications per minute per IP (per site). Every DELETE also counts against a separate limit of 60 per hour per key, whose 429 message names the delete limit. A 429 carries a Retry-After header.
History: every write is recorded in the site’s change history attributed to the person the key belongs to, with the key named (shown in the admin as Jordan Avery (via API key "CLI")).
Who am I
/api/v3/me/Describes the key and the site. Call this first.
{"api_version": "v3", "api_key": { "id": 3, "name": "CLI", "prefix": "rg_live_abcdEFGH", "scopes": ["blog:read", "blog:write"], "created_at": "...", "expires_at": "2027-03-03T07:32:04"}, "site": { "domain": "www.example.com", "name": "Example Realty", "current_url": "https://www.example.com", "blog_path": "/blog/", "timezone": "America/Chicago"}}
Command line (geekcli)
geekcli is the open-source Real Geeks command line, MIT licensed. It wraps every endpoint in these docs and is built to be driven by scripts and AI agents: JSON output when piped, JSON errors with stable exit codes, posts created as drafts, partial updates by default, and revisions you can revert. Install it on macOS or Linux:
curl -fsSL \ https://raw.githubusercontent.com/ realgeeks/geekcli/main/install.sh | sh
Windows, direct downloads and source builds are in the README. Then log in and read its guide, which is written for agents:
geekcli auth login --site www.yoursite.com geekcli me geekcli guide
The same guide is on GitHub as docs/GUIDE.md, and AGENTS.md has the short version for an agent working on a live site. Each section of these docs shows the matching geekcli commands.
Notes for tools and agents
- If you can run a shell, use geekcli instead of raw HTTP: it follows every rule below for you. Run
geekcli guidefirst. - Start with
GET /me/to confirm the key, its scopes and the site. - Send
"status": "draft"when creating posts unless publishing is intended. - Prefer
PATCHfor edits; send only the fields you change. - Read validation errors from
error.fields; the field names match the request body. - Read
warningson every successful write: the change was saved, but a warning usually means something to fix, such as a search value the site does not know or markup the sanitizer removed. - Do not resend HTML you read back unless you changed it, so admin-authored markup that the sanitizer would strip is left alone.
- Before changing something that exists, note its latest revision (
GET .../revisions/), so a bad result is one revert away: see Revisions and undo. A deleted or replaced file can be restored for 90 days. Deleting anything else has no undo, so confirm it first.
