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.
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
- Navigation, Sidebars, Footers & Featured Pages
- Design, Site Settings & Files
Enabling
The API is off by default. Real Geeks enables it per site; until then every /api/v3/ request returns 403 api_disabled and the API keys section is absent from the site admin. To have it enabled for your site, request access or contact Real Geeks support.
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.
Scopes
A key has scopes. A write scope implies the matching read scope.
| Area | Scopes | Covers |
|---|---|---|
| Blog | blog:read, blog:write |
/blog/posts/, /blog/categories/ |
| Content pages | pages:read, pages:write |
/content/pages/ |
| 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": "rg2 CLI on Kevin's 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.
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, api_disabled |
| 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 |
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, tables, div/span, figure, blockquote, pre/code, and iframe embeds from YouTube or Vimeo over https. Scripts, event handlers, javascript: links, forms, and style properties other than basic layout/colour 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.
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). 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 Kevin McCarthy (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"}}
Notes for tools and agents
- 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. - Do not resend HTML you read back unless you changed it, so admin-authored markup that the sanitizer would strip is left alone.
- The legacy
GET /api/v2/content/<model>/endpoints are unauthenticated and read-only; do not build on them.
