GeoAns Guide

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 (/media is 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_at and 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;
  • base must 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_token is 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 auth as "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"
  • since is 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_size is 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):

  1. Missing Idempotency-Key → 400, problem type missing-idempotency-key;
  2. Key hit → don't create again; return 201 + the original resource + an Idempotency-Replayed: true header;
  3. Key miss → persist, and write the key → resource mapping in the same transaction as the business data;
  4. Same key, different body → 409, idempotency-conflict;
  5. Keep mappings ≥7 days. When publishing, external_id equals the Idempotency-Key — a unique index on external_id lets 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.