← Clavis Sinica Docs · OpenAPI · llms.txt · Get a key · 中文

Clavis Sinica API — Integration Guide

For developers integrating the nine Chinese-language capabilities of ClavisSinica.org Updated 11 September 2026 Base URL https://clavissinica.org


0 Read this first

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.


1 Getting a key

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.

2 Authentication

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.


3 Request shape: always use 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.


4 Two execution modes

Every entry in GET /v1/chinese/services carries an execution.mode.

Synchronous (seven services)

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
}

Submit-then-poll (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.


5 provenance: looked up or generated

Every response carries:

"provenance": {"type": "lookup", "generated_fields": []}

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.


6 Rate limits: four layers, rejections are free

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.


7 Errors

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.

One real lesson

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.


8 Billing and idempotency

Idempotency is scoped to the account, not the key. Rotating a key mid-retry does not break it.


9 The nine capabilities

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

Counter-intuitive facts worth knowing early


10 Sandbox and free trial


11 A minimal working client

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.


12 Reference