For developers integrating the nine Chinese-language capabilities of ClavisSinica.org
Updated 11 September 2026
Base URL https://clavissinica.org
One integration, nine capabilities. All nine services share the same endpoint, the same auth scheme, the same request shape, the same error codes and the same billing rules. The only branch you need to write is synchronous versus poll.
POST /v1/chinese/run ← the only business endpoint
Authorization: Bearer ck-live_...
{"service_code": "<one of nine>", "payload": {...}}
Do not hardcode the field spec. Field names, types, length caps, enum values and required flags all live in GET /v1/chinese/services — free of charge, readable with any valid key. It is generated from the same definition the engine validates against. Fetch it at startup instead of copying it into your code.
This is not boilerplate advice. One integrator maintained a hand-copied local spec table; it accumulated five errors, each with the same root cause — the spec only existed in the server's code, and nothing told them when it changed. After switching to this endpoint they deleted three local tables.
Two steps, because single-step key issuance is an account-takeover risk.
# ① Request — returns 202, no key in the response
curl -X POST https://clavissinica.org/v1/keys \
-H 'Content-Type: application/json' \
-d '{"email": "you@example.com", "env": "live"}'
# {"ok":true,"status":"verification_sent","expires_in":900}
# ② Click the link in the email (valid 15 minutes, single use)
# The key appears exactly once, in that confirmation response.
# We store only a hash and cannot recover it for you.
ck-live_ — a hyphen, not an underscore. ck-test_ is a sandbox key that never spends real ckTok.pk_live_ keys from the sister product chinesekey.org do not work here. You get an explicit wrong_key_prefix error rather than a vague 401.Authorization: Bearer ck-live_xxxxxxxx
This is the only accepted scheme. There is no X-API-Key header and no query parameter. Anything else returns 401 missing_or_invalid_authorization.
payload{
"service_code": "decompose.idiom",
"payload": {"idiom": "守株待兔"},
"idempotency_key": "your-id-001" // optional
}
payload works for all nine services. The keys are the fields[].name values from GET /v1/chinese/services.
There is also a shorthand, {"input": "..."}, which only sets the primary field of single-field services. It exists for backward compatibility — if you are integrating now, ignore it and you will never have to reason about which services accept which shape.
Only four top-level keys are accepted: service_code, input, payload, idempotency_key. Anything else is silently dropped, but the response carries a warnings array telling you what went:
"warnings": ["请求顶层的这些字段不被支持, 已忽略: explain_lang。..."]
Log those warnings. One integrator sent an explain_lang field at the top level for months. It was discarded every time; calls succeeded, responses looked normal, and nothing ever signalled the problem. That array exists because of it.
Every entry in GET /v1/chinese/services carries an execution.mode.
The result comes back directly, typically in 0.1–1.5 seconds.
{
"ok": true,
"service_code": "decompose.idiom",
"data": { ... },
"provenance": {"type": "lookup", "generated_fields": []},
"cktok_charged": 10,
"replayed": false,
"test_mode": false
}
decompose.lesson, decompose.wenyan_lesson)These take 45–90 seconds — too long to hold an HTTP connection open.
// The submit returns immediately
{"ok": true, "task_id": "task_xxx", "status": "pending", "poll_url": "/v1/chinese/tasks/task_xxx"}
Then poll GET /v1/chinese/tasks/{task_id} every few seconds until status is done or error.
provenance: looked up or generatedEvery response carries:
"provenance": {"type": "lookup", "generated_fields": []}
type: "lookup" — the result comes from our own datasets. Deterministic: the same input always produces the same output.type: "generative" — a language model was involved. generated_fields names which top-level keys in data were produced that way.If you are storing results as authoritative content, read this field. Lookup results can be cited directly; generated ones deserve a source note or human review.
For decompose.lesson, six of the seven layers are generated. The character layer is not — semantic and phonetic components, stroke order and original meaning each come from a separate structured source, with the original meaning traced to a specific entry in an 11,114-entry classical lexicon.
| Layer | Keyed on | Limit | On trigger |
|---|---|---|---|
| nginx | IP | 1,200/min | plain 429 |
| Application | account | 120/min | scope: account_requests_per_minute |
| Application | account, in-flight async tasks | 3 | scope: account_inflight_tasks |
| Application | global, in-flight async tasks | 20 | scope: global_task_queue_full |
{"detail": {"error": "rate_limited", "scope": "account_inflight_tasks",
"msg": "...", "limit": 3, "retry_after": 60}}
A Retry-After header comes with it. Honour it; do not retry in a tight loop. Rate-limited requests are not charged.
The per-IP layer is deliberately generous. If you integrate server-side, all of your customers' traffic leaves from one IP — a tight per-IP limit would make them crowd each other out. The three per-account layers are what actually constrain you.
The machine-readable code is in error; the human-readable message is in msg. Parameter errors also carry field and expected.
| HTTP | error | Meaning | Retry? |
|---|---|---|---|
| 400 | invalid_param |
Your parameters. field says which one |
Fix and resend |
| 400 | no_text |
OCR found no readable text | Try another image |
| 401 | missing_or_invalid_authorization |
Header missing or malformed | Check the Bearer header |
| 401 | wrong_key_prefix |
A pk_live_ or other non-ck- key |
Use the right key |
| 401 | invalid_or_revoked_key |
Key invalid or revoked | Issue a new one |
| 404 | not_found |
The service works; the content is not in the dictionary | Do not retry |
| 413 | payload_too_large |
Over the declared size cap | Compress and resend |
| 415 | unsupported_media_type |
Image format not accepted | Convert to JPEG/PNG/WebP |
| 429 | rate_limited |
See section 6 | Follow Retry-After |
| 502 | upstream_error |
Timeout or connection failure; the engine was never reached | Reasonable to retry |
| 502 | upstream_rejected |
Upstream failed in a way we cannot classify | Retry cautiously |
| 503 | upstream_unavailable |
An upstream dependency is down | Should retry |
Note the difference between 4xx and 5xx here. A 4xx means your request has a problem and retrying will not help. upstream_error and upstream_unavailable have nothing to do with your request; backing off and retrying is correct.
Anything charged but not delivered is refunded automatically, and the response says so in a note field.
An earlier version had a catch-all error code named engine_rejected_input, applied to any failure that did not carry an explicit status — including upstream timeouts, where the engine was never called at all. An integrator consequently misread a paste error in their own test script as a structural mismatch between the published spec and the engine.
Error codes now reflect actual cause. A server-side failure will not be described as a problem with your input.
idempotency_key: the same key charges once, returns the first result, and sets replayed: true.idempotency_key with a different body returns 409. That is a real misuse, not something to paper over.GET /v1/account/balance / ledger / usage.Idempotency is scoped to the account, not the key. Rotating a key mid-retry does not break it.
Prices and shapes below are a summary; GET /v1/chinese/services is authoritative.
| service_code | ckTok | Mode | Returns |
|---|---|---|---|
convert.script |
2 | sync | Simplified/Traditional conversion, four directions |
ocr.image |
6 | sync | Image to text |
decompose.english_morph |
8 | sync | English prefix/root/suffix, root family |
decompose.idiom |
10 | sync | Meaning, example, source citation, HSK level, register |
decompose.wenyan |
12 | sync | Per-character original meaning, Shuowen citation, six-category |
decompose.sentence |
15 | sync | Tokens, pinyin, grammar explanation, per-word drill-down |
decompose.passage |
20 | sync | Readability verdict, new-word rate, HSK distribution, patterns |
decompose.lesson |
100 | poll | Seven-layer lesson breakdown, modern Chinese |
decompose.wenyan_lesson |
100 | poll | Seven-layer lesson breakdown, classical Chinese |
decompose.passage's target_level is an HSK 3.0 level (1–9), not a school grade. Converting from a grade produces a completely wrong difficulty verdict, and nothing errors.decompose.idiom returns both languages (meaning_zh / meaning_en). The others either have no language dimension or are fixed to one.decompose.sentence's grammar explanation is always English. It is not switchable.decompose.passage's priority_words[].gain is a formatted Chinese sentence, not a number. Display it as-is; sort by rank.grammar_patterns_coverage tells you how many were truncated; the other metrics cover the whole text.truncated: true, every metric reflects only the first 3,000 characters — not just the patterns.ocr.image takes one image per call, up to 6 MB after base64 encoding, and rejects HEIC (the iPhone default) with a message saying so. A ten-page chapter is ten calls.ck-test_ prefix, no real ckTok spent, 50 calls per service per day (decompose.wenyan_lesson: 3). Good for CI.POST /v1/demo/run, same body shape, no key, 10 calls per day per IP, six of the nine services (the two async lesson services and ocr.image are excluded).import time, requests
BASE = "https://clavissinica.org"
class Clavis:
def __init__(self, key):
self.h = {"Authorization": f"Bearer {key}",
"Content-Type": "application/json"}
# Fetch the spec at startup rather than hardcoding it
self.spec = requests.get(f"{BASE}/v1/chinese/services",
headers=self.h, timeout=30).json()
self.mode = {s["service_code"]: s["execution"]["mode"]
for s in self.spec["services"]}
def run(self, code, payload, idem=None, _tries=0):
body = {"service_code": code, "payload": payload}
if idem:
body["idempotency_key"] = idem
r = requests.post(f"{BASE}/v1/chinese/run", headers=self.h,
json=body, timeout=180)
if r.status_code == 429 and _tries < 5:
time.sleep(int(r.headers.get("Retry-After", 30)))
return self.run(code, payload, idem, _tries + 1)
if r.status_code in (502, 503) and _tries < 3:
time.sleep(2 ** _tries)
return self.run(code, payload, idem, _tries + 1)
r.raise_for_status()
out = r.json()
for w in out.get("warnings", []):
print("WARN", w) # do not ignore these
if self.mode.get(code) == "submit_poll":
return self._poll(out["task_id"])
return out
def _poll(self, task_id, timeout=300):
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(5)
t = requests.get(f"{BASE}/v1/chinese/tasks/{task_id}",
headers=self.h, timeout=30).json()
if t.get("status") in ("done", "error"):
return t
raise TimeoutError(task_id)
Official clients do exactly the above and nothing more:
pip install clavis-sinica
npm install @clavis-sinica-org/sdk
The two registries use different naming conventions (PyPI has no scopes); it is the same API. Calling the HTTP endpoint directly is equally supported — the clients do nothing you cannot do yourself.
GET /v1/chinese/services — free, authoritativepk_live_ keys, not interchangeable.