---
name: ankinize-sdk
description: Read and change an Anki collection with the official Ankinize TypeScript SDK (@ankinize/sdk): typed functions for decks, notes, note types, cards, tags, media and the study queue, batch helpers, type-safe searches and type-safe note types. Use when writing or running TypeScript or JavaScript that adds, finds, edits or reviews Anki cards on an Ankinize server.
---

# Ankinize TypeScript SDK

`@ankinize/sdk` is the REST API as plain typed functions: one per endpoint, named after the operation id, on standard `fetch`, with no dependencies. It runs on Node 20+, Bun, Deno, Cloudflare Workers and browsers, and is an ES module with its own type declarations.

Use it whenever you write TypeScript or JavaScript against Ankinize. For another language, or for a raw HTTP call, use the `ankinize-api` skill (https://ankinize.com/.well-known/agent-skills/ankinize-api/SKILL.md); everything about the API itself (auth, scopes, paging, error codes) is the same.

## Install and connect

```bash
bun add @ankinize/sdk   # or: npm install @ankinize/sdk / pnpm add @ankinize/sdk
```

Configure once at start-up, before the first call:

```ts
import { client } from "@ankinize/sdk";

client.setConfig({
  auth: process.env.ANKINIZE_API_KEY,
});
```

- The user creates the API key in the dashboard under *API Keys & Devices*; it is shown once. Read it from the environment (`ANKINIZE_API_KEY`), never from source code or a URL. Scopes: `read` allows every `get*`, `list*` and `render*` function; `write` allows everything. Ask for the fewest the task needs.
- Requests go to `https://ankinize.com` unless `baseUrl` says otherwise. This server is `https://ankinize.com`, so `baseUrl` can stay out. A self-hosted server always needs it.
- Run the SDK in code you control (a script, a server). An API key can read and change the whole Collection, so never ship it to a browser other people open.

## Calling a function

- Every function takes **one flat object**: path values, query values and body fields side by side. There is no `{ path, query, body }` wrapper: `getNote({ id })`, `listNotes({ q, limit })`, `createNote({ notetypeName, deckName, fields })`.
- It returns the parsed answer (not `{ data, error }`) and **throws** on any non-2xx. Responses and parameters are typed from the OpenAPI schemas; import types with `import type { Deck, Note } from "@ankinize/sdk"`.
- A function with nothing to send is called with no argument: `await listDecks()`. A second object overrides settings for that call only (`getNote({ id }, { auth: otherKey, baseUrl })`, `listDecks({}, { auth: otherKey })`), so calls for different people can run at once.
- Naming and shapes follow the REST API: camelCase everywhere; ids are numbers (keep them as integers); a note's `fields` is an object keyed by each field's camelCase name (`Back Extra` is `backExtra`); name a note type or deck with `notetypeName` / `deckName` (a nested deck is `Languages::German`) or use `notetypeId` / `deckId`, never both.

```ts
import { client, createNote, listDecks } from "@ankinize/sdk";

client.setConfig({
  auth: process.env.ANKINIZE_API_KEY,
});

const { decks } = await listDecks();
await createNote({
  notetypeName: "Basic",
  deckName: "Languages::German",
  fields: { front: "der Hund", back: "the dog" },
  tags: ["german"],
});
```

## Functions

The SDK exports exactly these functions; do not call or invent others. The request column is the REST endpoint behind it, for the exact parameters read the OpenAPI document or the TypeScript tab of each endpoint in the API reference.

| Function | Request | What it does | Scope |
| --- | --- | --- | --- |
| `listDecks` | `GET /api/v1/decks` | List decks | `read` |
| `createDeck` | `POST /api/v1/decks` | Create a deck | `write` |
| `getDeck` | `GET /api/v1/decks/{id}` | Get a deck by ID | `read` |
| `renameDeck` | `PATCH /api/v1/decks/{id}` | Rename a deck | `write` |
| `deleteDeck` | `DELETE /api/v1/decks/{id}` | Delete a deck and its subdecks | `write` |
| `listNotes` | `GET /api/v1/notes` | Search notes | `read` |
| `createNote` | `POST /api/v1/notes` | Create a note and generate its cards | `write` |
| `getNote` | `GET /api/v1/notes/{id}` | Get note by ID with cards | `read` |
| `updateNote` | `PATCH /api/v1/notes/{id}` | Update note fields or tags | `write` |
| `deleteNote` | `DELETE /api/v1/notes/{id}` | Delete a note and its cards | `write` |
| `listNoteTypes` | `GET /api/v1/note-types` | List note types | `read` |
| `createNoteType` | `POST /api/v1/note-types` | Create note type (stock or custom) | `write` |
| `getNoteType` | `GET /api/v1/note-types/{id}` | Get note type details with fields and templates | `read` |
| `updateNoteType` | `PATCH /api/v1/note-types/{id}` | Update note type fields, templates, or styling | `write` |
| `deleteNoteType` | `DELETE /api/v1/note-types/{id}` | Delete a note type | `write` |
| `getStudyQueue` | `GET /api/v1/queue` | Get the study queue of a deck | `read` |
| `listTags` | `GET /api/v1/tags` | List tags | `read` |
| `createTag` | `POST /api/v1/tags` | Create a tag | `write` |
| `getTag` | `GET /api/v1/tags/{name}` | Get a tag by name | `read` |
| `renameTag` | `PATCH /api/v1/tags/{name}` | Rename a tag | `write` |
| `deleteTag` | `DELETE /api/v1/tags/{name}` | Delete a tag | `write` |
| `getStats` | `GET /api/v1/stats` | Get the statistics of a search | `read` |
| `listMedia` | `GET /api/v1/media` | List media files | `read` |
| `getMedia` | `GET /api/v1/media/{name}` | Download a media file | `read` |
| `putMedia` | `PUT /api/v1/media/{name}` | Store a media file | `write` |
| `deleteMedia` | `DELETE /api/v1/media/{name}` | Delete a media file | `write` |
| `listCards` | `GET /api/v1/cards` | Search cards | `read` |
| `getCard` | `GET /api/v1/cards/{id}` | Get card details by ID | `read` |
| `updateCard` | `PATCH /api/v1/cards/{id}` | Edit a card | `write` |
| `deleteCard` | `DELETE /api/v1/cards/{id}` | Delete a card | `write` |
| `renderCard` | `GET /api/v1/cards/{id}/render` | Render a card's front and back | `read` |
| `getCardScheduling` | `GET /api/v1/cards/{id}/scheduling` | Preview the four answer buttons | `read` |
| `answerCard` | `POST /api/v1/cards/{id}/answer` | Answer (review) a card | `write` |

## Many at once: batch helpers

`createNotes`, `updateNotes`, `deleteNotes`, `createTags`, `updateTags`, `deleteTags`, `updateCards`, `deleteCards` take **one item or an array**. They are the only way the SDK reaches the batch endpoints (the one-call `*Batch` functions are not exported). They cut the array into calls the API accepts (200 notes, edits or tag names; 1000 ids or tag names to delete; under 4 MiB of body), send them one after the other, wait and retry on `503`, `429` and `423` (honouring `Retry-After`), and return one answer with the entries in the order you gave them.

| Function | Request | What it does | Scope |
| --- | --- | --- | --- |
| `createNotes` | `POST /api/v1/notes/batch-create` | Create many notes in one call | `write` |
| `updateNotes` | `POST /api/v1/notes/batch-update` | Edit many notes in one call | `write` |
| `deleteNotes` | `POST /api/v1/notes/batch-delete` | Delete many notes in one call | `write` |
| `createTags` | `POST /api/v1/tags/batch-create` | Create many tags in one call | `write` |
| `updateTags` | `POST /api/v1/tags/batch-update` | Rename many tags in one call | `write` |
| `deleteTags` | `POST /api/v1/tags/batch-delete` | Delete many tags in one call | `write` |
| `updateCards` | `POST /api/v1/cards/batch-update` | Edit many cards in one call | `write` |
| `deleteCards` | `POST /api/v1/cards/batch-delete` | Delete many cards in one call | `write` |

```ts
import { createNotes } from "@ankinize/sdk";

const { created, failed, results } = await createNotes(notes);
for (const r of results) if (!r.ok) console.log(r.index, r.error);
```

- An entry the Collection refuses is reported in `results`; the rest are written. `{ atomic: true }` writes all or nothing, and only when everything fits one call (more throws a `RangeError` before anything is sent).
- A call that cannot go on (a bad key, say) throws; if earlier calls had already gone through, it throws a `PartialBatchError` whose `completed` holds their answers. Options: `signal`, `client`, `maxRetries`, `maxRetryWaitMs`.

## Searching

Every list is narrowed by one search, `q`: `listNotes`, `listCards`, `listDecks`, `listTags` and `listNoteTypes` take it, and nothing else narrows them (the `deckId`, `notetypeId`, `noteId` and `tag` parameters are gone; sending one is `422`). **Search on the server**; do not page through a collection and filter it in code. `q` is Anki search text (`"tag:vocab front:dog*"`, the syntax of the Anki browser) or a **search value** built with `q` from the SDK, which writes the text and its escaping for you and is checked against the list it is sent to.

```ts
import { listNotes, q } from "@ankinize/sdk";

const { notes } = await listNotes({
  q: q.and(
    q.tag("vocab"),
    q.field("Front", q.glob("dog*")),
    q.not(q.is("suspended")),
  ),
  limit: 50,
});
```

- A plain string is **literal**: it matches those characters in any case, and `*` and `_` are just characters. Other ways to match are patterns: `q.glob("dog*")` (`*` any run, `_` one character; `q.escapeGlob(text)` makes text you did not write literal inside one), `q.regex("^d.g")`, `q.accents("cafe")` (ignores accents) and `q.fuzzy("japnese")`.
- Terms: `q.text(x)` (any field; on lists of names, the name), `q.field(name, value)` (the field's exact name, `Back Extra`, not its key; `""` finds an empty field), `q.tag(x)`, `q.untagged`, `q.deck(x)` (a deck and everything under it), `q.notetype(x)`, `q.is(state)`, `q.flag(n)`, `q.card(n | name)`, `q.added(days)`, `q.edited(days)`, `q.introduced(days)`, `q.rated(days, button?)`, `q.rescheduled(days)`, `q.prop("ivl", ">=", 21)`, `q.nid(...ids)`, `q.cid(...ids)`, `q.did(...ids)` (one deck, not its children; a card in a filtered deck counts for its home deck too), `q.mid(id)`, `q.word(x)`, `q.withoutClozes(x)`, `q.duplicates(notetypeId, text)`, `q.preset(name)`, custom data (`q.customNumber`, `q.customText`, `q.hasCustomData`), and `q.raw("...")` for Anki search text the builders do not cover.
- Combine with `q.and(a, b, ...)`, `q.or(a, b, ...)` and `q.not(a)`. Inside a `pipe`, `q.andAlso(b)`, `q.orElse(b)` and `q.butNot(b)` continue from the search before them. Parentheses are added only where the meaning needs them.
- `q.fuzzy` (`fz:`) is ours, not Anki's: it tolerates typos, word by word. Every word of the search must be close to a word of the note, in any order (up to 4 letters exact, up to 8 one typo, longer two). It only says yes or no; results come in id order, never ranked.
- **Notes and cards.** A card matches when it satisfies the whole search. A note matches when *one* of its cards does, so `q.and(q.deck(x), q.is("due"))` is a note with a due card in that deck. A note without cards is found only by a search that asks nothing of cards (tags and fields).
- **Names.** On `listDecks`, `listTags` and `listNoteTypes`, `q` filters names: text matches anywhere in the name (a glob has `*` and `_` as wildcards), `q.regex`, `q.accents` and `q.fuzzy` work, and so does the list's own term (`q.deck` on decks, `q.tag` on tags, `q.notetype` on note types). A term about cards (`q.is`) does not compile there, and as a string it is `400`.
- A search the server cannot read is `400 invalid_input` with the reason; the builders refuse what they cannot write (an empty text, a bad number) with a `RangeError` before anything is sent. A search may be 1000 characters and 50 terms.

## Paging

`listNotes` and `listCards` return one page and a `nextCursor`. To read every match, use `streamNotes` or `streamCards`: an async iterable that fetches the next page only when the last is used up, until `nextCursor` is `null`. A search can return a short or empty page while `nextCursor` is still set, so never stop at an empty page. Stopping early reads no more pages.

```ts
import { iter, pipe, q, streamNotes } from "@ankinize/sdk";

for await (const note of streamNotes({ q: q.tag("vocab") })) {
  console.log(note.fields.front);
}

// the first 20 fronts
const fronts = await pipe(
  streamNotes({ q: q.tag("vocab") }),
  iter.map((note) => note.fields.front ?? ""),
  iter.take(20),
  iter.toArray,
);
```

By hand, pass `nextCursor` back as `cursor` until it is `null`: `listNotes({ q, limit: 100, cursor })`. `listDecks`, `listTags` and `listNoteTypes` are not paged.

## Composing results

Everything is a plain function, so results can be passed through steps. `pipe(value, f, g, ...)` runs a value through functions and `flow(f, g, ...)` builds the function. `iter.map`, `iter.filter`, `iter.take` and `iter.toArray` are data-last steps for async iterables (arrays have their own methods). `indexBy(key)`, `groupBy(key)` and `nest(name)` take any iterable: `pipe(decks, nest((d) => d.name))` makes a tree from the `::` in names (a level that exists only as a parent has no `item`), and `pipe(decks, indexBy((d) => d.id))` a `Map`.

## Errors and retries

Any failed call throws an `AnkinizeError` (a normal `Error`) with `status`, `code` (branch on this: `not_found`, `rate_limited`, `locked`, `full_sync_required`, ...), `message` and `response` (the raw `Response`, body already read, for `Retry-After`). A network failure throws something else, so check `instanceof`.

```ts
import { AnkinizeError, createDeck } from "@ankinize/sdk";

try {
  await createDeck({ name: "Languages::German" });
} catch (error) {
  if (!(error instanceof AnkinizeError)) throw error;
  if (error.status === 423 || error.status === 429) {
    // Anki is syncing, or the plan's rate limit is used up. Nothing changed.
    const seconds = Number(error.response.headers.get("Retry-After") ?? 1);
    await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
    // ...send the same request again
  } else console.error(error.code, error.message);
}
```

- `423` and `429` change nothing: wait `Retry-After` seconds and send the same call again. The batch helpers do this themselves; single-call functions do not.
- To make a write safe to retry, create `const idempotencyKey = crypto.randomUUID()` **once** and pass it on every attempt: `createNote({ ..., idempotencyKey })`. The SDK sends it as the `Idempotency-Key` header, and a retry returns the first answer instead of repeating the work.
- Changing a note type's fields or templates forces a full sync on every device, so `updateNoteType` and `deleteNoteType` (and a few card changes) throw `409 full_sync_required` until the call repeats with `acceptFullSync: true`. Do that only when the user agrees. Removing a field, template or note type also needs *Allow destructive schema changes via API* switched on in the dashboard.

## Media

A file goes up as a `Blob` and comes down as one; there is nothing to encode.

```ts
import { openAsBlob } from "node:fs";
import { getMedia, putMedia } from "@ankinize/sdk";

await putMedia({ name: "cat.png", body: await openAsBlob("cat.png") });
const file = await getMedia({ name: "cat.png" }); // a Blob
```

## Type-safe names

Out of the box `notetypeName`, `deckName` and the keys of `fields` are plain strings. To have the compiler check them, generate a declaration for the user's own Collection once and keep it in the project (run it again after a note type, field or deck is added):

```ts
import { client, generateTypes } from "@ankinize/sdk";

client.setConfig({ auth: process.env.ANKINIZE_API_KEY });
await Bun.write("ankinize.d.ts", await generateTypes());
```

It augments `Register` with `noteTypes` (each note type's field keys), `fieldNames` (their exact names, as searches read them), `decks` and, with `generateTypes({ tags: true })`, `tags` (left out by default: thousands of tags make a union that slows the compiler). After that `createNote` and `createNotes` reject a misspelt name or a `fields` key the note type does not have, and the terms `q.deck`, `q.notetype`, `q.field` and `q.tag` reject a name that is not there (pass a pattern such as `q.glob("Lang*")` for a wildcard). Notes made by `notetypeId` are not checked. Use `getNoteType` to list a note type's field keys when you have no declaration.

## Pitfalls

- Do not wrap arguments (`createNote({ body: {...} })`); the SDK spreads path, query and body into one object.
- Do not fetch a whole collection to filter it in code, and do not build search text by joining strings: use `q` and its terms, which escape for you. `deckId`, `notetypeId`, `noteId` and `tag` are not list parameters any more.
- Do not look for `{ data }` on the result; the result is the data.
- Do not import from deep paths such as `@ankinize/sdk/gen/...`; everything public comes from the package root.
- `client.setConfig({ fetch })` replaces the SDK's own fetch, which is what builds `AnkinizeError`; with a custom fetch a failed call throws the API's JSON body `{ error: { code, message } }` instead.
- Do not create a new `client` per request; the shared `client` is configured once. Pass `{ auth, baseUrl }` as the second argument to use another key or server for one call.

## More

- Guide: [https://ankinize.com/docs/sdk](https://ankinize.com/docs/sdk) (Markdown: https://ankinize.com/docs/sdk.md); API reference with a TypeScript sample for every endpoint: [https://ankinize.com/docs/api](https://ankinize.com/docs/api).
- Exact parameters and responses: https://ankinize.com/api/v1/openapi.json. The REST skill: https://ankinize.com/.well-known/agent-skills/ankinize-api/SKILL.md.
- Everything on this site as Markdown: [https://ankinize.com/llms-full.txt](https://ankinize.com/llms-full.txt).
