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

Clavis Sinica API — Integration Guide

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


0 Read this first

One integration, eleven capabilities. All eleven 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 ten>", "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.


0b Don't guess field names — there are free real examples

GET /v1/chinese/examples                          index, everything at once
GET /v1/chinese/services/{service_code}/example   one service

Needs a key, costs zero ckTok — it reads a pre-generated file and never calls the engine.

Each example is a complete request and response from a real call, plus response_fields: field names, types, nullability and the shape of array items, all derived from the real values rather than hand-written.

Several services ship more than one variant, because a single sample hides things: decompose.english_morph has one word whose parts is an empty array, one whose gloss is an empty string, and one complete form. Probe a single word and you will miss the first two.

Use these as your test fixtures. A fixture copied from a real response cannot drift from one; a hand-written fixture will, and that kind of mismatch does not raise an error — it just renders nothing.

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 eleven 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 (nine 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": [],
  "data_version": "b09a900c0dcb",
  "code_version": "4cf2acc3a0d4"
}

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.

Version stamps

Two fingerprints ride along, each covering a different thing.

A changed stamp means "something that could affect this result moved", not "your answer changed". We deliberately over-report. Telling you something changed when it did not costs you one extra call; telling you nothing changed when it did leaves you citing content that has quietly drifted. Neither value is a semantic version — compare for equality, never for order.

Both also appear under versions in GET /v1/chinese/services, which costs no ckTok, so a periodic "has anything moved" check is free.


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.

You do not have to wait for a rejection to find out where you stand. Every successful call reports it:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 60

Reset is the number of seconds until the oldest call in the window ages out — that is, until one slot frees up. Asynchronous services add X-RateLimit-Async-Remaining (your own in-flight tasks, of 3) and X-RateLimit-Queue-Remaining (the shared queue, of 20). The same headers come back on a 429, alongside Retry-After.

Browser clients can read all of these, and X-ckTok-Balance too: they are listed in Access-Control-Expose-Headers.

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

When you are charged

rate limits → parameter validation → charge → run → automatic refund on failure

Missing required parameters and malformed input are rejected before the charge, so nothing is written to your ledger at all.

Three exceptions, because those checks live inside the engine and therefore run after the charge: image too small, oversize (413) and wrong format (415). These are charged then refunded — net cost zero, but your ledger will show a debit and a refund. Worth knowing when you reconcile.

Balance

Every /v1/chinese/run response carries a header:

X-ckTok-Balance: 432

No separate call needed. Alert when this drops below the price of your most expensive service — decompose.lesson costs 100, so at a balance of 80 that capability returns 402 for every user while your own health checks stay green.

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


9 The eleven capabilities

Prices and shapes below are a summary; GET /v1/chinese/services is authoritative.

service_code ckTok Mode Returns
dict.multilingual 4 sync One Chinese character or word → its glosses in each language the dictionary covers. Not general-purpose translation
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.passage_deep 45 sync All seven layers of a passage in one call: gist, readability, segmentation, characters, words, per-sentence reading, syntax
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 Known limitations

Written here rather than left for you to discover:

13 Reference