Appendix: Site Plugin Contract (API)
The contract-v2 interface specification with request examples — for developers implementing the integration on their site.
This appendix is the technical companion to "Connect Your Site", written for site-side developers implementing the GeoAns publishing contract on a WordPress plugin or any CMS adapter layer. If you use the official site plugin you can skip it — install the plugin, generate a token, and click "Test connection".
In one sentence: the GeoAns platform is the HTTP client and your website is the server. Implement the nine JSON endpoints below and the platform can publish, update, and delete articles on your site, upload images, load your category tree, and sync your existing content into its knowledge base.
Endpoint → feature mapping (a missing endpoint means the feature is unavailable):
| Endpoint | Platform feature it supports |
|---|---|
GET /health |
"Test connection" / contract check (the gate for everything) |
GET /categories |
Loading your category tree; binding channels to categories |
POST /media |
Media distribution (images uploaded straight to your site); manual image upload in content management |
POST /channels/{key}/articles |
Article publishing (plan tasks / manual generation) |
GET /channels/{key}/articles |
Content management lists, publishing reconciliation, site title scraping (a keyword source) |
GET /channels/{key}/articles/{id} |
Fetching a published article's URL (needed by index checking) |
PATCH /channels/{key}/articles/{id} |
Content refresh (in-place updates), content management editing |
DELETE /channels/{key}/articles/{id} |
Content management deletion |
GET /channels/{key}/articles/export |
Syncing existing content into the platform knowledge base (KB sync) |
General conventions
- Request and response bodies are UTF-8 JSON (
/mediais multipart). There is no{code,message,data}envelope: the HTTP status code is the result, and a 2xx body is the resource itself; - Timestamp fields are Unix epoch seconds (e.g.
created_at);published_atand similar are RFC 3339 strings; - Unknown fields are tolerated: ignore anything you don't recognize — future fields are never breaking changes;
- Routes must tolerate trailing slashes (
/articles/equals/articles); - The single-request timeout is 15 seconds (5 to connect); the platform reads at most 4MB per response and fetches lists/exports at most 50 rows per page;
- The platform only reaches public addresses (private/loopback IPs are rejected), so your site must be publicly reachable; the platform may route through a SOCKS5 relay — transparent to you.
Entry & deployment shapes
Entry formula (the only assembly rule): ENTRY = trimTrailingSlashes(base) + "/api/v2", where base is the "request address" you fill in Site Management. Three deployment shapes, all equivalent:
| Shape | base | Actual request |
|---|---|---|
| rewrite | https://www.example.com |
https://www.example.com/api/v2/health |
| PATH_INFO | https://www.example.com/api/index.php |
https://www.example.com/api/index.php/api/v2/health |
| query routing | as above | https://www.example.com/api/index.php?route=/api/v2/health |
- Entry auto-discovery: filling just the domain root also works. The platform handshakes in rewrite → PATH_INFO → query order and writes the winning entry back to the site record — all three are equivalent, and mistakes self-correct;
basemust not contain a query, fragment, or user info;- Recommendation: use rewrite where possible (
.htaccess/ Nginx rules / framework routing); use query routing on locked-down servers.
Authentication
Credentials travel through one channel only:
Authorization: Bearer {site_token}
site_tokenis a shared secret held on both sides, ≥32 high-entropy random characters (the platform UI offers a "Generate" button); every endpoint, health included, carries it;- Failure →
401; credential channels other than Authorization (URL query, custom headers) are ignored; - Rotation: a new token applies from the next request — no plugin restart needed (read the token from config/cache at request time, never bake it in at startup). Changing both sides separately creates a brief inconsistency window (a 401 is a permanent failure and is not retried) — rotate off-peak and click "Test connection" right after; if the plugin supports old and new tokens side by side, there is no window;
- health reports
authas"bearer"(a signed variant exists in the spec, but the platform does not sign today — a site that enforces signatures gets 401 on everything).
Endpoint overview
| # | Method | Path | Idempotency-Key | Meaning |
|---|---|---|---|---|
| 1 | GET | /api/v2/health |
— | Health check + version/capability negotiation |
| 2 | GET | /api/v2/categories |
— | Site category tree |
| 3 | POST | /api/v2/media |
required | Image upload (multipart) |
| 4 | POST | /api/v2/channels/{key}/articles |
required | Publish an article |
| 5 | GET | /api/v2/channels/{key}/articles |
— | List articles |
| 6 | GET | /api/v2/channels/{key}/articles/{id} |
— | Article detail |
| 7 | PATCH | /api/v2/channels/{key}/articles/{id} |
— | Partial update (Merge Patch) |
| 8 | DELETE | /api/v2/channels/{key}/articles/{id} |
— | Delete an article |
| 9 | GET | /api/v2/channels/{key}/articles/export |
— | Incremental export (KB sync) |
The two endpoints marked "required" must reject a missing header with 400 and problem type missing-idempotency-key. {key} is the channel identifier (e.g. news), matching ^[a-z][a-z0-9_]{1,31}$.
Endpoints in detail, with examples
1. GET /health — contract handshake
curl -H "Authorization: Bearer $TOKEN" "$ENTRY/health"
{
"contract_version": "2",
"plugin": "mp-connector/1.0.0",
"system": "WordPress 6.5 / PHP 8.2",
"tls": true,
"auth": "bearer",
"entry": "rewrite",
"token_set": true,
"time": "2026-09-22T12:00:00+08:00"
}
Handshake test = contract_version is "2" and token_set is true. Passing sets the site to ok and unlocks all business calls; failing blocks publishing with the reason shown. tls reports the protocol the request actually arrived on; plugin/system/entry are free-form diagnostics.
2. GET /categories — category tree
{
"items": [
{ "id": 1, "title": "News", "children": [
{ "id": 12, "title": "Industry" },
{ "id": 13, "title": "Company" }
]},
{ "id": 2, "title": "Products" }
]
}
The platform reads each item's id (sent back as category_id), title, and children[]. Flat implementations may use pid instead of nesting; leaves must carry id and title.
3. POST /media — image upload
curl -X POST "$ENTRY/media" \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: geo-mat-88-site-3" \
-F "file=@./cover.jpg"
The multipart field is fixed as file; the Idempotency-Key is required — replaying a key must return the original media URL, never a second file. Accept JPEG/PNG/WebP (GIF optional), ≤10MB per file, 413 beyond that.
{ "id": "med_3fa2", "url": "https://www.example.com/uploads/2026/09/cover.jpg", "width": 1280, "height": 720 }
The platform reads url (required; missing = failed upload).
4. POST /channels/{key}/articles — publish an article
POST /api/v2/channels/news/articles HTTP/1.1
Authorization: Bearer mp_tk_9f2c…
Idempotency-Key: 0b8df2e0-5f7a-4c6a-9b1e-3a2c1d4e5f60
Content-Type: application/json
{
"title": "How to get found in AI search",
"content": "<h2>…</h2><p>…</p>",
"summary": "GEO basics in one piece.",
"category_id": 12,
"cover_url": "https://www.example.com/uploads/2026/09/cover.jpg",
"tags": ["GEO", "AI search"],
"seo": { "title": "GEO 101", "description": "…", "keywords": ["geo", "ai search"] },
"status": "published",
"published_at": "2026-09-22T12:00:00+08:00",
"language": "zh-CN",
"external_id": "0b8df2e0-5f7a-4c6a-9b1e-3a2c1d4e5f60"
}
| Field | Notes |
|---|---|
title / content |
Title; body as an HTML fragment |
summary |
List summary (may equal seo.description) |
category_id |
Category ID; invalid values get 422 |
cover_url |
Cover image URL (usually from /media) |
tags |
Array of tag names — find-or-create on sites with a tag library, plain strings otherwise; never require tag IDs from the platform |
seo |
{title, description, keywords[]}; keywords is an array |
status |
"published" | "draft" (the platform always sends published; draft must be accepted) |
published_at |
Explicit publish time, RFC 3339 |
language |
Optional BCP 47 code (configured per plan) — multilingual sites file the article under that language version; the same external_id always resolves to the same article regardless of language |
external_id |
The platform task identifier (same value as the Idempotency-Key when publishing) — store it verbatim |
Success (idempotent replays add an Idempotency-Replayed: true header):
HTTP/1.1 201 Created
{ "id": 876, "url": "https://www.example.com/news/876.html", "status": "published", "external_id": "0b8df2e0-…" }
The platform reads id (a number > 0; missing or ≤0 counts as failure) and url (consumed by index checking; must be returned).
Security red line: store cover_url and other URL fields as plain strings and never fetch them server-side inside the plugin — images are already uploaded to your site via /media; server-side fetching only adds an SSRF surface to your site.
5. GET /channels/{key}/articles — list
curl -H "Authorization: Bearer $TOKEN" \
"$ENTRY/channels/news/articles?page=1&page_size=10&category_id=12&external_id=0b8df2e0-…"
Supports page/page_size (≤50), category_id, keyword (title fuzzy match), status (published|draft), and external_id (exact filter).
{
"items": [
{ "id": 876, "title": "…", "url": "https://…/876.html", "category_id": 12,
"status": "published", "external_id": "0b8df2e0-…",
"created_at": 1725260400, "updated_at": 1725260400 }
],
"page": 1, "page_size": 10, "total": 42
}
The external_id filter is a hard requirement: actually filter by it, and echo it back verbatim on matching rows — the platform drops rows whose echo differs. This is the deterministic lookup behind crash recovery and replay confirmation.
6. GET /channels/{key}/articles/{id} — detail
{ "id": 876, "title": "…", "url": "https://…/876.html", "status": "published", "content": "…", "external_id": "0b8df2e0-…", "updated_at": 1725260400 }
The platform reads url (fetched after publishing). Missing → 404.
7. PATCH /channels/{key}/articles/{id} — partial update
The body is JSON Merge Patch (RFC 7386): only present keys change. The platform's whitelist:
{ "title": "New title", "content": "<p>New body</p>", "summary": "…", "seo": { "description": "…" } }
Red line: updates must never change the article URL/slug or status. Respond 200 or 204; the platform doesn't read the response body. Merge Patch is naturally idempotent — no Idempotency-Key. Content refresh updates pages in place (URL unchanged), so this red line protects both your SEO and the platform's index monitoring.
8. DELETE /channels/{key}/articles/{id} — delete
Success → 204 (empty body); missing → 404.
9. GET /channels/{key}/articles/export — incremental export (KB sync)
curl -H "Authorization: Bearer $TOKEN" \
"$ENTRY/channels/news/articles/export?since=1725260400&page=1&page_size=50"
sinceis an epoch-seconds cursor; implement the filter with>=(a strict>permanently skips same-second rows across pages; repeated boundary rows are deduplicated by the platform via URL + content hash);- Return rows ordered by
(updated_at ASC, id ASC)— descending order breaks multi-page sync after the first page; - Keep single responses ≤3.5MB (the platform reads at most 4MB); returning fewer rows than
page_sizeis legal, and pagination continues normally.
{
"items": [
{ "id": 876, "title": "…", "content": "<p>Full HTML</p>",
"url": "https://…/876.html", "cover_url": "https://…/cover.jpg",
"updated_at": 1725260500, "type": "article" }
]
}
Row fields: title/url required (url is the platform dedup key), updated_at required (cursor), content recommended as the KB corpus, type: "product" marks product entries.
Idempotency & errors
Idempotency rules (create and media):
- Missing
Idempotency-Key→400, problem typemissing-idempotency-key; - Key hit → don't create again; return
201+ the original resource + anIdempotency-Replayed: trueheader; - Key miss → persist, and write the
key → resourcemapping in the same transaction as the business data; - Same key, different body →
409,idempotency-conflict; - Keep mappings ≥7 days. When publishing,
external_idequals the Idempotency-Key — a unique index onexternal_idlets you implement article idempotency as "INSERT hits the index → return the existing row"; a mapping table is only truly needed for/media.
Errors: every non-2xx response uses Content-Type: application/problem+json:
{
"type": "channel-disabled",
"title": "Channel disabled",
"status": 409,
"detail": "Channel product is disabled and cannot receive publishing"
}
type/title/detail are shown verbatim to users in the platform's task errors — write readable text. Status semantics: 201 created (idempotent replays included), 204 deleted, 400 malformed request, 401 authentication, 404 missing, 409 conflict (disabled/idempotency), 413 too large, 422 validation, 429 rate-limited (send Retry-After), 5xx transient.
Platform retry classes: network errors / 429 / 5xx → retried with the same key; 400/401/403/404/409/413/422 → not retried, the task fails with the problem details; non-JSON responses (HTML error pages / WAF pages) → treated as transient and retried.
Self-test suite (curl)
TOKEN="your generated 32+ character random string"
ENTRY="https://www.example.com/api/v2"
# 1) Handshake — contract_version must be "2", token_set must be true
curl -sS -H "Authorization: Bearer $TOKEN" "$ENTRY/health"
# 2) Category tree — items[].id/title must be present
curl -sS -H "Authorization: Bearer $TOKEN" "$ENTRY/categories"
# 3) Image upload (replaying the key must return the same url)
curl -sS -X POST "$ENTRY/media" \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: selftest-media-001" \
-F "file=@./test-cover.jpg"
# 4) Publish (replaying the whole command: same id + Idempotency-Replayed: true)
curl -sS -X POST "$ENTRY/channels/news/articles" \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: selftest-art-001" \
-H "Content-Type: application/json" \
-d '{"title":"Integration self-test","content":"<p>hello geoans</p>","summary":"test",
"category_id":12,"status":"published","external_id":"selftest-art-001"}'
# 5) List + exact external_id filter (matching rows must echo it verbatim)
curl -sS -H "Authorization: Bearer $TOKEN" \
"$ENTRY/channels/news/articles?page=1&page_size=10&external_id=selftest-art-001"
# 6) Detail (url must be present)
curl -sS -H "Authorization: Bearer $TOKEN" "$ENTRY/channels/news/articles/876"
# 7) Partial update (merge-patch: only listed keys change)
curl -sS -X PATCH "$ENTRY/channels/news/articles/876" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Integration self-test (edited)"}'
# 8) Incremental export (since=0 for full; ordered by updated_at)
curl -sS -H "Authorization: Bearer $TOKEN" \
"$ENTRY/channels/news/articles/export?since=0&page=1&page_size=50"
# 9) Delete
curl -sS -X DELETE -H "Authorization: Bearer $TOKEN" "$ENTRY/channels/news/articles/876" -i
# Negative paths: missing key must 400; wrong token must 401
curl -sS -X POST "$ENTRY/media" -H "Authorization: Bearer $TOKEN" -F "file=@./t.jpg" -i
curl -sS -H "Authorization: Bearer wrong-token" "$ENTRY/health" -i
Troubleshooting
| Symptom (shown in the platform) | Cause | Fix |
|---|---|---|
Handshake failed: contract_version mismatch |
health doesn't report "2", or the route misses the contract endpoint |
Check the health response; verify the entry shape |
Handshake failed: token_set=false |
The plugin hasn't saved a token | Save a non-empty token in the plugin and report it truthfully |
| "The site returned an HTML page instead of API data" | rewrite not configured / WAF blocking / plugin disabled | Open $ENTRY/health in a browser; switch to PATH_INFO or query shape |
| Outbound protection blocked the request | Site address resolves to a private/loopback IP | Site must be publicly reachable (or ask us for a relay) |
| Everything returns 401 | Tokens differ on the two sides / another auth layer intercepts | Re-check tokens; exempt /api/v2 from other auth |
Publish 409 channel-disabled |
The channel is disabled site-side | Enable the channel or contact support |
| KB sync stops after page one / misses articles | export ordering or since filter implemented wrong |
Fix to ascending order + >= |
Implementation note
Channels are platform-side namespaces and unbounded; your implementation count should equal the number of content types (one for page-style content) — let every articles endpoint transparently write the same content model for any {key}, and never implement per channel. If one channel truly needs separate storage, dispatch by key inside the plugin; the contract doesn't change.