Repository navigation
API
Full Python client for AnkiWeb's protobuf-based /svc/* endpoints — the same
wire protocol the AnkiWeb web frontend uses. There is no official, supported
public AnkiWeb API: endpoints and schemas here are reverse-engineered, may
change without notice, and are subject to AnkiWeb's terms and rate limits. Use
only with an account and data you are authorized to access.
/svc/radmin/* administrative endpoints are decoded in the schema but
intentionally not exposed on the client.
Coverage: 41/41 public endpoints as typed AnkiWebClient methods (48
methods counting the two live-discovered binary endpoints), plus a
complete field-level reference in ENDPOINTS.md. Schema revision
2026-10-10 (174 messages; all request/response/nested names resolve).
Related Anki APIs — this page is only one of several surfaces. The
/svc/*protocol documented here is specific to AnkiWeb's website (add-ons, shared decks, study, account). Other official surfaces live elsewhere: the sync protocol / self-hosted sync server in SYNC.md (collection + media sync over/sync/*and/msync/*), the AnkiDroid app APIs in ANKIDROID.md (ContentProvider, intents, JS bridge), and the desktop add-on Python API — see ECOSYSTEM.md for the full map and how they differ.
CLI usage, installation and the GitHub Action live in the README. Jump to: install · authentication · method reference · errors · escape hatch · schema · codec · upload · limits & caching · testing
The 22
/svc/radmin/*admin endpoints are decoded but not exposed on the client — see reference only.
- Installation
- Authentication
- Method reference — session · account · decks · editor · search · shared · study
- Errors and status codes
- Generic escape hatch
- Schema regeneration
- Codec
- Upload verification
- Description limits and stale reads
- Testing
uv tool install ankiweb-api # provides: ankiweb, ankiweb-upload, ankiweb-test-addon
# or
pipx install ankiweb-apiAs a library, the only runtime dependency is requests:
uv pip install requests # or: pip install requestsPut credentials in ./.env (gitignored — never commit it):
ANKIWEB_USERNAME=you@example.com
ANKIWEB_PASSWORD=...AnkiWebClient() auto-loads .env from the current directory, falling back to
the directory holding the installed module. Exported environment variables win,
and username= / password= override both.
Authentication is a single ankiweb session cookie obtained from
POST /svc/account/login. The client attaches it automatically and re-logs in on
demand, so most callers never handle cookies.
from ankiweb_client import AnkiWebClient
client = AnkiWebClient() # reads .env
client.login() # caches the session cookie
client.is_authenticated() # -> True
# Or reuse an existing cookie from another tool:
client = AnkiWebClient(cookie="eyJvcCI6...")logout() drops the cached cookie. If AnkiWeb expires the session, the next
call raises AnkiWebError("Session expired or invalid credentials") and clears
the cookie; calling login() again recovers.
These are safe to call unauthenticated, and the client will not trigger a login for them:
| Endpoint | Method |
|---|---|
/svc/account/login |
POST |
/svc/account/signup |
POST |
/svc/account/get-account-status |
POST |
/svc/account/reset-password |
POST |
/svc/account/resend-verification |
POST |
/svc/account/verify-email |
POST |
/svc/shared/item-info |
GET |
/svc/shared/list-addons |
GET |
/svc/shared/list-decks |
GET |
Everything else sends Cookie: has_auth=1; ankiweb=<session>.
Method names mirror the path: /svc/shared/list-mine → shared_list_mine.
POST methods take keyword arguments matching the request fields; GET methods
take shared_id= or query={...}. Field-level detail for every request and
response is in ENDPOINTS.md.
auth below means the call requires a session cookie.
| Method | auth | Returns |
|---|---|---|
login(username=None, password=None) |
— | the session cookie (str) |
logout() |
— | None |
is_authenticated() |
— | bool |
list_mine() |
yes | alias of shared_list_mine()
|
item_info(addon_id) |
no | alias of shared_item_info(shared_id=addon_id)
|
| Method | auth | Returns |
|---|---|---|
account_login(username, password) |
no | {status, token} |
account_signup(username, password) |
no | {status, ankiuser_login_token?} |
account_get_account_status() |
no | {logged_in, redirect_to?} |
account_reset_password(email_to='', check_token='', new_password=None) |
no | {send_email, token_ok, set_new_password} |
account_resend_verification(hkey='') |
no | {throttled} |
account_verify_email(token='') |
no | {token_verified, sending_status{...}} |
account_check_terms() |
yes | {needs_to_confirm} |
account_confirm_terms(agree_to_terms=True) |
yes | empty |
account_get_settings() |
yes | {current_email} |
account_update_settings(current_password='', new_password='', new_email='') |
yes |
{result} enum |
account_total_media() |
yes | {total_bytes} |
account_remove_account(current_password) |
yes |
{status, media_removed} — irreversible
|
| Method | auth | Returns |
|---|---|---|
decks_create_deck(name) |
yes | empty |
decks_deck_list_info(minutes_west_of_utc=None) |
yes | {top_node, current_deck_id, collection_size_bytes, media_size_bytes} |
decks_deck_share_info(deck_id) |
yes | {metadata, is_large_user, share_count} |
decks_deck_share(metadata, confirm_copyright=True) |
yes | empty |
decks_deck_share_state() |
yes | {state, shared_id?} |
decks_rename_deck(deck_id, new_name) |
yes | {error} |
decks_remove_deck(deck_id) |
yes | empty |
decks_select_deck(deck_id) |
yes | empty |
| Method | auth | Returns |
|---|---|---|
editor_get_info_for_adding() |
yes | {notetypes[], decks[], current_deck_id, ...} |
editor_get_note_info(note_id) |
yes | {fields[], notetype_fields[], tags} |
editor_get_notetype_fields(notetype_id) |
yes | {fields[]} |
editor_add_or_update(request) also lives in this area but takes the full
request mapping (nested fields, current_notetype_id, audio, video,
picture) rather than keyword arguments — see
SCHEMA["EditorAddOrUpdateRequest"] and ENDPOINTS.md.
| Method | auth | Returns |
|---|---|---|
search_search(search) |
yes | {notes[{note_id, sorted_field_names...}], error} |
search uses Anki's own query language, e.g. deck:My\ Deck added:7.
| Method | auth | Returns |
|---|---|---|
shared_item_info(shared_id=None, query=None) |
no | {available{title, tags, description, size, last_updated, support_url, addon{versions[]}, ...}, missing, access_denied} |
shared_list_addons() |
no | {rows[], user_upload_status} |
shared_list_decks(search="") |
no |
{rows[]} — search is required; empty search returns no rows |
shared_download_deck(shared_id, dest=None, max_bytes=None) |
no | The shared deck's .apkg bytes (or byte count when dest is given); auto-retries logged-in on 429 |
shared_media_preview(shared_id, filename, last_updated=None) |
no | Raw bytes of one media preview file (audio/image) from /shared/mpreview/
|
shared_items_by_author(shared_id) |
yes | {items[{shared_id, title}]} |
shared_get_addon_info(addon_id) |
yes | {addon{title, tags, support_url, description, branches[]}} |
shared_list_mine() |
yes | {items[], reviews[], expired_decks[]} |
shared_upload_addon(info, file=None) |
yes | {addon_id} |
shared_remove_item(shared_id) |
yes | empty — deletes the shared item |
shared_get_own_review(shared_id) |
yes | {review{body, thumbs_up}, shared_item_title, support_page?} |
shared_set_own_review(shared_id, review) |
yes | empty; review={body, thumbs_up}
|
shared_reply_to_review(shared_id, hash, body) |
yes | empty |
shared_report_copyright(shared_id) |
yes | empty |
shared_download_deck and shared_media_preview are binary/file endpoints that
carry no protobuf message — see
ENDPOINTS.md — discovered live.
shared_upload_addon takes the nested info mapping directly, e.g.:
client.shared_upload_addon(
info={
"addon_id": 805854302, # omit to create a new add-on
"title": "My add-on",
"tags": "anki addon",
"support_url": "https://github.com/athulkrishna2015/ankiweb-api",
"description": "Markdown is supported.",
"branches": [{"min_version": 1, "max_version": 250904}],
},
file={"zip_data": Path("my.ankiaddon").read_bytes(), "branch_index": 0},
)Prefer upload.py / ankiweb-upload for uploads — it wraps this endpoint with
branch-notation parsing and the action's own INPUT_* mapping. See
Upload verification and the README.
| Method | auth | Returns |
|---|---|---|
study_get_deck_limits() |
yes | {new, review, new_cards_ignore_review_limit, error} |
study_set_deck_limits(new, review, new_cards_ignore_review_limit=False) |
yes | empty |
study_study_cards(answer=None, next_card_id=None) |
yes | {sched_ver, cards[], new_count, learn_count, review_count} |
study_upgrade_scheduler(v3=True) |
yes | empty |
| Exception | Module | Raised when |
|---|---|---|
AnkiWebError |
ankiweb_client, terminal_ankiweb
|
any non-2xx response, network failure, or decode failure |
NotFoundError |
ankiweb_client, terminal_ankiweb
|
HTTP 404, or a 200 with an empty payload (wrong/private id) |
ValidationError |
ankiweb_client |
a required request object is missing, or a request field is not in the schema |
AuthError |
ankiweb_auth |
login failed or returned no ankiweb cookie |
ProtoError |
ankiweb_proto |
malformed protobuf input |
AnkiWebError is the base for NotFoundError, so catching the base class is
enough. The client's status handling is:
| Status | Behaviour |
|---|---|
200 |
decoded via the endpoint's response message |
401, 403
|
clears the cached cookie, raises AnkiWebError ("Session expired or invalid credentials") |
404 |
raises NotFoundError
|
| anything else | raises AnkiWebError with the status and a truncated body |
AnkiWeb reports some failures in the body, not the status — e.g. uploading
an add-on whose title is already taken returns HTTP 400 with
title must be unique. Always read the exception message.
Any public endpoint is also reachable by method name without a typed wrapper, which is useful while exploring or when the schema gains a field the wrapper does not expose yet:
client.shared_list_mine() # typed
client.call("shared_list_mine") # dynamic, same call
client.editor_add_or_update(request={...}) # typed, full request mappingcall accepts request= for POST bodies, query= for GET parameters, and
response_as= to force a specific response message when a shared variable made
the generator's inference ambiguous.
From the terminal the same escape hatch is exposed as ankicli.py call:
ankiweb endpoints # list all 41
ankiweb call shared_item_info --query sharedId=2055492159
ankiweb call decks_create_deck --request '{"name": "New Deck"}'- Read-only/public methods (
shared_item_info,shared_list_addons,shared_list_decks,shared_list_mine, ...) are safe to explore. - Account actions (
update_settings,account_remove_account,decks_deck_share,shared_remove_item,shared_upload_addon, ...) have real effects. Prefer synthetic or offline fixtures during development. Never test uploads with a real add-on file — an upload replaces the file on AnkiWeb irreversibly; follow the synthetic-fixture instructions in the README. -
/svc/radmin/*endpoints are decoded but deliberately not exposed. They act on other users' accounts and several are destructive; they are listed as reference only so the decode coverage stays auditable, and the client refuses to synthesise a wrapper for them (AttributeError).
The generator decodes protobuf definitions and call sites from AnkiWeb's live
frontend bundle (Vite __vite__mapDeps discovery + precise
k(VAR,new REQ(n),RESP,e) call-site parsing). Refresh with:
python3 tools/generate_schema.py # download + regenerate
python3 tools/generate_schema.py --bundle /path/to/frontend.mjs
python3 tools/generate_schema.py --json out.json # also dump raw decodeReview generated diffs before accepting them. tools/generate_schema.py keeps
SHARED_RESPONSE_ALIASES (verified response-var corrections) and a pinned
ENDPOINT_RESPONSE_OVERRIDES sanity entry for login. While the live test passes,
regeneration is byte-for-byte stable — see Testing.
ankiweb_proto.py provides encode(message_name, mapping) and
decode(message_name, bytes) — pure stdlib, no protobuf dependency. Supports
scalar, repeated (packed/unpacked), nested, optional and map fields, and
retains unknown fields for round-tripping.
from ankiweb_proto import encode, decode
from ankiweb_schema import SCHEMA
encode("AccountLoginRequest", username="me", password="pw")
decode("AccountLoginResponse", raw_bytes) # -> {'status': 1, 'token': '...'}Offline regression tests:
python3 -m unittest -v test_ankiweb_proto.pyThe GitHub Actions upload path (node dist/index.js, wrapped locally by
upload.py) is verified live against the private throwaway add-on 805854302
(saved as ANKIWEB_TEST_ADDON_ID in the gitignored .env): upload a synthetic
fixture from make_test_addon.py, confirm the new description/timestamp via
ankicli.py info, and confirm the re-downloaded bytes are sha256-identical to
the upload. The same path is covered on real runners by the CI test-action job
(run 38121891143, green). See the README for the exact
commands and latest verified snapshot.
Two AnkiWeb behaviours that look like data loss but are not. Both are handled in this client; neither is a server bug you can work around by changing the uploader.
Size limit: 65,535 bytes. Measured 2026-10-11 on the throwaway add-on by bisection: 65,535 stores, 65,536 does not. An over-limit upload is rejected, not truncated — the add-on keeps whatever description it already had, so a failed large upload looks like "my new text vanished" while the old text is still live. Verify after every large description upload.
item-info is cached for 10 minutes (Cache-Control: max-age=600). Reads of
a stable URL can therefore return pre-upload state, which again looks like a
silent wipe. fetch_item_info_raw sends the same cache-buster AnkiWeb's own
frontend uses — cb=<Date.now()> in epoch milliseconds, from its cacheBust
helper — plus Cache-Control: no-cache, and surfaces the value as
info["cache_bust"] so ankicli.py info can print a verify: URL that matches
the data it just read:
./ankicli.py info 805854302
# ...
# verify: https://ankiweb.net/shared/info/805854302?cb=1791710745423Without the cb query the browser will happily show you stale data for up to ten
minutes.
_next_cache_bust() is strictly monotonic rather than a bare Date.now(): two
reads inside the same millisecond would otherwise produce the same URL, and the
second could be served the first one's cached copy.
Note
AnkiWebClient.shared_item_info()does not cache-bust — it is the raw client. For description reads that must reflect a recent upload, useterminal_ankiweb.fetch_item_info(), which is what the CLI and the test suite use.
python3 test_terminal_ankiweb.py # offline, no network
python3 test_terminal_ankiweb.py --live # + live round-trips + API discovery
python3 -m unittest -v test_ankiweb_proto.py # codec and schema regressions
pnpm test # GitHub Action unit tests (mocked)Any test that reads live item-info must go through fetch_item_info() rather
than building a URL by hand. A stable URL means the assertions may silently run
against pre-upload state. The live suite checks this explicitly:
python3 test_terminal_ankiweb.py --live- consecutive reads must carry different cache-busters and agree on content
- a long (>20,000 char) description must survive the round-trip — this is the regression guard for the 5,000-byte parser cap that made uploads look wiped
- the live bundle is re-discovered and diffed against the committed schema, so new or removed endpoints fail loudly instead of drifting
While this passes, python3 tools/generate_schema.py rewrites
ankiweb_schema.py byte-for-byte.