# AnkiWeb API — full endpoint reference (41 public endpoints) > **Status: full compatibility.** Every non-admin `/svc/*` endpoint served by > AnkiWeb's web frontend is decoded and callable via `AnkiWebClient`. > Schema revision: **2026-10-10** — 174 messages, 41 public + 22 radmin endpoints. > Regenerate any time with `python3 tools/generate_schema.py`. > > AnkiWeb exposes no official public API. These endpoints are reverse-engineered > from the site's own frontend bundle, may change without notice, and are > subject to AnkiWeb's terms and rate limits. The 22 `/svc/radmin/*` admin > endpoints are decoded into the schema for completeness but are **not** exposed > on the client — see [reference only](#admin-endpoints-reference-only-not-callable). > Use only with an account and data you are authorized to access. **Jump to:** [Account](#account-12) · [Decks](#decks-8) · [Editor and search](#editor-and-search-4) · [Shared public reads](#shared-public-reads-5-no-auth) · [Shared account reads and writes](#shared-account-reads-and-writes-7-auth) · [Study](#study-4) · [Admin (reference only)](#admin-endpoints-reference-only-not-callable) · [Discovered live](#discovered-live-beyond-the-schema) · [Wire details](#wire-details) > Client concepts, safety rules, and schema regeneration are in > [API.md](API.md); installation and the CLI are in the [README](https://github.com/athulkrishna2015/ankiweb-api). > > **Related APIs:** this reference covers `/svc/*` (AnkiWeb's website JSON > API) only. Collection/media sync is a **separate protocol** served under > `/sync/*` and `/msync/*` — see [SYNC.md](SYNC.md); AnkiDroid's on-device APIs > are in [ANKIDROID.md](ANKIDROID.md); [ECOSYSTEM.md](ECOSYSTEM.md) maps every > surface. ## Quick start ```python from ankiweb_client import AnkiWebClient client = AnkiWebClient() # reads .env (ANKIWEB_USERNAME / ANKIWEB_PASSWORD) client.login() mine = client.shared_list_mine() print(mine["items"]) item = client.shared_item_info(shared_id=2055492159) # no auth needed print(item["available"]["title"]) ``` Method names are `_`, e.g. `account_login`, `shared_list_mine`, `decks_create_deck`, `study_study_cards`. Legacy aliases `login`, `list_mine`, `item_info` still work. Auth: POST endpoints attach the `ankiweb` session cookie automatically (login first), except the no-auth account endpoints (`login`, `signup`, `reset-password`, `resend-verification`, `verify-email`, `get-account-status`) and the three public GET endpoints (`item-info`, `list-addons`, `list-decks`). --- ## Account (12) | Method | Endpoint | Auth | Request | Response | | -------------------------------------------------------------------- | --------------------------------------- | ---- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `account_login(username, password)` | POST `/svc/account/login` | no | `username`, `password` | `{status, token}` — status: UNKNOWN / AUTHENTICATED / INVALID_USER / INVALID_PASS | | `account_check_terms()` | POST `/svc/account/check-terms` | yes | — (empty) | `{needs_to_confirm: bool}` | | `account_confirm_terms(agree_to_terms=True)` | POST `/svc/account/confirm-terms` | yes | `agree_to_terms: bool` | — (empty) | | `account_get_account_status()` | POST `/svc/account/get-account-status` | no | — (empty) | `{logged_in: bool, redirect_to?: string}` | | `account_get_settings()` | POST `/svc/account/get-settings` | yes | — (empty) | `{current_email: string}` | | `account_update_settings(current_password, new_password, new_email)` | POST `/svc/account/update-settings` | yes | `current_password`, `new_password`, `new_email` | `{result}` — UNKNOWN / PASSWORD_CHANGED / EMAIL_CHANGED / INVALID_CURRENT_PASSWORD / INVALID_EMAIL / EMAIL_UNAVAILABLE / SPAM_REPORT | | `account_signup(username, password)` | POST `/svc/account/signup` | no | `username`, `password` | `{status, ankiuser_login_token?}` — UNKNOWN / SUCCESS / USERNAME_INVALID / USERNAME_TAKEN / SPAM_EMAIL | | `account_verify_email(token="")` | POST `/svc/account/verify-email` | no | `token?: string` | `{token_verified: bool, sending_status{updated, progress, email}}` — progress: UNKNOWN / SENDING / DELIVERED / SPAM / QUOTA / OTHER_DELAY / FAILED | | `account_resend_verification(hkey="")` | POST `/svc/account/resend-verification` | no | `hkey?: string` | `{throttled: bool}` | | `account_reset_password(email_to, check_token, new_password?)` | POST `/svc/account/reset-password` | no | `email_to`, `check_token`, `new_password{token, new_password}?` | `{send_email, token_ok: bool, set_new_password}` — send_email: UNKNOWN / THROTTLED / INVALID_EMAIL / SUCCESS | | `account_total_media()` | POST `/svc/account/total-media` | yes | — (empty) | `{total_bytes: uint64}` | | `account_remove_account(current_password)` | POST `/svc/account/remove-account` | yes | `current_password` | `{status, media_removed: uint32}` — status: UNKNOWN / VALID_PASSWORD_REQUIRED / IN_PROGRESS / FINISHED. **Irreversible.** | ## Decks (8) | Method | Endpoint | Request | Response | | ---------------------------------------------------- | ---------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `decks_create_deck(name)` | POST `/svc/decks/create-deck` | `name: string` | — (empty) | | `decks_deck_list_info(minutes_west_of_utc?)` | POST `/svc/decks/deck-list-info` | `minutes_west_of_utc?: int32` | `{top_node{deck_id, name, level, collapsed, review/learn/new_count, ...}, current_deck_id: int64, collection_size_bytes: uint32, media_size_bytes: uint64}` | | `decks_deck_share_info(deck_id)` | POST `/svc/decks/deck-share-info` | `deck_id: int64` | `{metadata{title, tags, support_url, description, deck_id, shared_id?}, is_large_user: bool, share_count: uint32}` | | `decks_deck_share(metadata, confirm_copyright=True)` | POST `/svc/decks/deck-share` | `metadata{...}`, `confirm_copyright: bool` | — (empty) | | `decks_deck_share_state()` | POST `/svc/decks/deck-share-state` | — (empty) | `{state, shared_id?}` — state: NO_ACTIVE_SHARE / WAITING / IN_PROGRESS / SUCCESS / TOO_LARGE / UNKNOWN_ERROR | | `decks_rename_deck(deck_id, new_name)` | POST `/svc/decks/rename-deck` | `deck_id: int64`, `new_name: string` | `{error: string}` (empty on success) | | `decks_remove_deck(deck_id)` | POST `/svc/decks/remove-deck` | `deck_id: int64` | — (empty) | | `decks_select_deck(deck_id)` | POST `/svc/decks/select-deck` | `deck_id: int64` | — (empty) | ## Editor and search (4) | Method | Endpoint | Request | Response | | ----------------------------------------- | -------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------- | | `editor_get_info_for_adding()` | POST `/svc/editor/get-info-for-adding` | — (empty) | `{notetypes[], decks[], current_deck_id/notetype_id: int64, fields[]}` | | `editor_get_note_info(note_id)` | POST `/svc/editor/get-note-info` | `note_id: int64` | `{fields[]: string, notetype_fields[], tags: string}` | | `editor_get_notetype_fields(notetype_id)` | POST `/svc/editor/get-notetype-fields` | `notetype_id: int64` | `{fields[]}` | | `editor_add_or_update(request)` | POST `/svc/editor/add-or-update` | full `EditorAddOrUpdateRequest` (see `SCHEMA`) | `{...}` | | `search_search(search)` | POST `/svc/search/search` | `search: string` | `{notes[{...}], error: string}` | ## Shared public reads (5, no auth) | Method | Endpoint | Request | Response | | ----------------------------------- | ----------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `shared_item_info(shared_id)` | GET `/svc/shared/item-info?sharedId=` | query `sharedId: uint32` | `{available{title, description, tags, versions[], size, last_updated, reviews[]...}, missing: bool, access_denied: bool}` — cached 600s server-side | | `shared_list_addons()` | GET `/svc/shared/list-addons` | — | `{rows[{id, title, thumbs_up/down, mtime, max_supported_version}], user_upload_status}` — UNSPECIFIED / NO_USER / TOO_NEW / CAN_UPLOAD | | `shared_list_decks(search="")` | GET `/svc/shared/list-decks?search=` | — (but **`search` required**) | `{rows[{id, title, thumbs_up/down, mtime, notes, audio, images, ...}]}` | | `shared_items_by_author(shared_id)` | POST `/svc/shared/items-by-author` | `shared_id: uint32` | `{items[{shared_id, title}]}` | | `shared_get_addon_info(addon_id)` | POST `/svc/shared/get-addon-info` | `addon_id: uint32` | `{addon{addon_id?, title, tags, support_url, description, branches[{min_version, max_version}]}}` | ## Shared account reads and writes (7, auth) | Method | Endpoint | Request | Response | | ----------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `shared_list_mine()` | POST `/svc/shared/list-mine` | — (empty) | `{items[{id, title, thumbs_up/down, mtime, downloads, max_supported_version?}], reviews[{review, item_id, hash}], expired_decks[]}` | | `shared_remove_item(shared_id)` | POST `/svc/shared/remove-item` | `shared_id: uint32` | — (empty) | | `shared_get_own_review(shared_id)` | POST `/svc/shared/get-own-review` | `shared_id: uint32` | `{review{body, thumbs_up}, shared_item_title, support_page?}` | | `shared_set_own_review(shared_id, review)` | POST `/svc/shared/set-own-review` | `shared_id`, `review{body: string, thumbs_up: bool}` | — (empty) | | `shared_reply_to_review(shared_id, hash, body)` | POST `/svc/shared/reply-to-review` | `shared_Id` (! exact wire name), `hash: uint32`, `body: string` | — (empty) | | `shared_report_copyright(shared_id)` | POST `/svc/shared/report-copyright` | `shared_id: uint32` | — (empty) | | `shared_upload_addon(info, file?)` | POST `/svc/shared/upload-addon` | `info{addon_id?, title, tags, support_url, description, branches[]}`, `file{zip_data: bytes, branch_index: uint32}?` | `{addon_id: uint32}` — also wrapped by `upload.py` / the GitHub Action | ## Study (4) | Method | Endpoint | Request | Response | | ------------------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `study_get_deck_limits()` | POST `/svc/study/get-deck-limits` | — (empty) | `{new: uint32, review: uint32, new_cards_ignore_review_limit: bool, error: string}` | | `study_set_deck_limits(new, review, new_cards_ignore_review_limit=False)` | POST `/svc/study/set-deck-limits` | `new`, `review: uint32`, `new_cards_ignore_review_limit: bool` | — (empty) | | `study_study_cards(answer?, next_card_id?)` | POST `/svc/study/study-cards` | `answer{card_id, answer_button, time_taken_millis, answered_at_millis, current/next_state}?`, `next_card_id?: int64` | `{sched_ver: uint32, cards[{card_id, question, answer, count_index, button_labels[], note_id, ...}], new/learn/review_count}` | | `study_upgrade_scheduler(v3=True)` | POST `/svc/study/upgrade-scheduler` | `v3: bool` | — (empty) | ## Admin endpoints — reference only, not callable > [!CAUTION] > **Reference only. Not part of the supported surface.** The 22 `/svc/radmin/*` > endpoints below are decoded in `SCHEMA` — all request/response/nested names > resolve — but are **deliberately not exposed** on `AnkiWebClient` and have no > typed wrapper. They require AnkiWeb staff privileges and act on _other users' > accounts_, so several are destructive and irreversible (`ban-user`, > `clear-reviews`, `bulk-remove-reviews`, `move-addon`, `restore-collection-backup`, > `balance-users`). They are listed so the decode coverage is honest and auditable, > **not** as an invitation to call them. They remain reachable through the codec if you deliberately construct a request (`ankiweb_proto.encode("RadminBanUserRequest", {"user_id": 1})`), because removing them from the schema would misreport what was actually decoded. Don't, unless you are AnkiWeb staff debugging a live incident. | Reference name (no wrapper exists) | Endpoint | Request fields | Response fields | | ---------------------------------- | -------------------------------------------- | ---------------------------------------------- | ------------------------------------------ | | `radmin_all_shared_items` | POST `/svc/radmin/all-shared-items` | `user_id` | `items`, `reviews` | | `radmin_balance_users` | POST `/svc/radmin/balance-users` | `batch_size`, `batch_count`, `shards_to_drain` | — (empty) | | `radmin_ban_user` | POST `/svc/radmin/ban-user` | `user_id` | — (empty) | | `radmin_become_user` | POST `/svc/radmin/become-user` | `user_id` | `token` | | `radmin_bulk_disable_sharing` | POST `/svc/radmin/bulk-disable-sharing` | `user_ids`, `reason` | — (empty) | | `radmin_bulk_remove_reviews` | POST `/svc/radmin/bulk-remove-reviews` | `user_ids`, `reason` | — (empty) | | `radmin_clear_reviews` | POST `/svc/radmin/clear-reviews` | `user_id`, `mode` | — (empty) | | `radmin_dump_all_addons` | POST `/svc/radmin/dump-all-addons` | — | — (empty) | | `radmin_email_status` | POST `/svc/radmin/email-status` | `email`, `action` | `sent`, `changed`, `verified`, `state` | | `radmin_export_revlogs` | POST `/svc/radmin/export-revlogs` | `shard`, `options` | — (empty) | | `radmin_fix_media` | POST `/svc/radmin/fix-media` | `user_id`, `dry_run` | `files_processed` | | `radmin_get_collection_backups` | POST `/svc/radmin/get-collection-backups` | `user_id` | `backups` | | `radmin_get_user_info` | POST `/svc/radmin/get-user-info` | `user_id` | `user` | | `radmin_list_media` | POST `/svc/radmin/list-media` | `user_id` | `entries` | | `radmin_lookup_users` | POST `/svc/radmin/lookup-users` | `search` | `matched_users`, `first_match`, `ip_match` | | `radmin_migrate_user` | POST `/svc/radmin/migrate-user` | `user_id`, `target_shard` | — (empty) | | `radmin_migrate_users` | POST `/svc/radmin/migrate-users` | `source_shard`, `target_shard`, `count` | — (empty) | | `radmin_move_addon` | POST `/svc/radmin/move-addon` | `shared_id`, `target_user_id` | — (empty) | | `radmin_recalculate_ratings` | POST `/svc/radmin/recalculate-ratings` | `shared_id` | — (empty) | | `radmin_restore_collection_backup` | POST `/svc/radmin/restore-collection-backup` | `user_id`, `version` | — (empty) | | `radmin_restore_media` | POST `/svc/radmin/restore-media` | `user_id`, `days`, `dry_run` | `files_processed` | | `radmin_support_notes` | POST `/svc/radmin/support-notes` | `user_id`, `new_note` | `notes` | > [!NOTE] > The first column is a **documentation label, not a method name**. There is no > `AnkiWebClient.radmin_ban_user()` — calling it raises `AttributeError`. Only > the 41 public endpoints in the sections above are callable. The client enforces this in code, not just in docs: `public_endpoints()` filters `/radmin/` out of the schema's endpoint list, and `AnkiWebClient.__getattr__` refuses to synthesise a wrapper for any path that is admin-only. ## Wire details - Transport: `POST` with `Content-Type: application/octet-stream` (protobuf bytes), except the three GET endpoints which pass `URLSearchParams`. - Session: `Cookie: has_auth=1; ankiweb=` + `Referer: https://ankiweb.net/shared/mine`, exactly as the web app sends. - Verified live 2026-10-10: `GET /svc/shared/item-info?sharedId=2055492159` → 200 → decodes as `SharedItemInfoResponse` via `ankiweb_proto.decode`. - Field numbers/types: see `ankiweb_schema.SCHEMA` (generated, authoritative as of the revision date). Regenerate with `python3 tools/generate_schema.py` and review the diff before accepting. ### This is not the sync protocol AnkiWeb serves two unrelated HTTP surfaces; only the first is documented here: | | `/svc/*` (this page) | `/sync/*` + `/msync/*` | | --------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | | Purpose | Website features: add-ons, shared decks, study, account | Collection & media sync between clients and a server | | Encoding | Protobuf messages in octet-stream bodies (codec: [API.md](API.md#codec)) | JSON (or multipart/gzip for legacy clients), zstd-compressed, `anki-sync` header envelope | | Auth | `ankiweb` session cookie (or none for public reads) | Host key from `POST /sync/hostKey` (`{u, p}`) | | Official? | No — reverse-engineered from the frontend bundle | Yes — official protocol, client and server in `ankitects/anki` | | Reference | [ENDPOINTS.md](ENDPOINTS.md) (here) | [SYNC.md](SYNC.md) | There is no overlap: an endpoint under `/svc/*` never appears under `/sync/*`, and a self-hosted sync server serves neither `/svc/*` nor any website features. In-app "AnkiWeb" messages during sync refer to the sync server, even against a custom one. ## Discovered live, beyond the schema Discovered 2026-10-11 by sweeping **every** chunk of AnkiWeb's live frontend bundle (the schema generator only reads the single `frontend.*.mjs` chunk) and probing the endpoints with a live session. These carry **no protobuf message**, so they can never appear in the generated schema — they are binary file endpoints wired up as plain links in the SvelteKit routes. All were verified live. | Endpoint | Auth | What it does | Client | | ----------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | | `GET /svc/shared/download-deck/?t=` | public | Downloads a shared deck's **`.apkg`** file (zip). The one-time `download_key` is a short JWT (`{"op":"sdd",...}`) minted by `item-info` under `available.deck.download_key`. Verified: `200`, `Content-Disposition: ...apkg`, zip magic `PK\x03\x04`, ~111 MB deck | `shared_download_deck()` | | `GET /shared/mpreview//?` | public | Media **preview** files for a shared deck (audio/images). The query key is the bare `last_updated` timestamp (`...?1697739853=`). Verified: `200 audio/mpeg` for `0.mp3` | `shared_media_preview()` | | `/svc/radmin/collection//download` | admin only | Direct collection download link on the radmin user page. **404 for non-admin sessions** | — (reference only) | | `/svc/radmin/get-collection-backup//` | admin only | Single backup download (distinct from the protobuf `get-collection-backups` list). **404 for non-admin sessions** | — (reference only) | | `/svc/radmin/get-media//` | admin only | Single media file download on the radmin page. **404 for non-admin sessions** | — (reference only) | Two live behaviours worth knowing (both discovered by probing): 1. **`list-decks` requires the `search` query field.** The schema registers no query params, but the server rejects a bare call with `400 Failed to deserialize query string: missing field 'search'`. An empty search returns an empty result; pass a term to list decks. 2. **Anonymous use is rate-limited.** After a handful of anonymous calls the server answers `429 Please log in to perform more searches` (list-decks, search) and `429 Please log in to download more decks` (download-deck). The client therefore retries once with a session cookie when credentials are available (`login_on_429=True`), which lifts the limit. The live test suite (`python3 test_terminal_ankiweb.py --live`) now sweeps all chunks and asserts this exact set: any _new_ `/svc/` path that appears in the bundle fails the `no unknown /svc paths across all chunks` check, and the `download-deck` / `mpreview` endpoints are exercised end-to-end.