duck.study API

Duck API

Duck keeps your flashcards and schedules their reviews. This API lets Claude, or a script of your own, read your cards and add new ones. It comes in two forms on the same host: an HTTP API under /v1, and an MCP server at /mcp that Claude connects to. Both act as you, with full access to your account, and both use the same token. They cannot review cards: reviews stay in the app.

Get a token

Open Settings, API access and press New token. The window that opens copies the token on a tap; Show in the list opens it again later. A token starts with duck_. Revoke it in the same window; a revoked token stops working at once. A token has no expiry and no scopes: treat it like a password.

Authenticate

Send the token as a bearer header on every request. Never put it in a URL.

curl https://api.duck.study/v1/cards \
  --header "Authorization: Bearer duck_..."

A missing, unknown or revoked token answers 401 with a WWW-Authenticate: Bearer header:

{"error":"unauthenticated","message":"Send a valid access token as Authorization: Bearer duck_..."}

The api host serves nothing but the API and this page. Requests are JSON in, JSON out; send Content-Type: application/json with a body.

Connect Claude

Claude Code

Verified by us. Run once:

claude mcp add --transport http duck https://api.duck.study/mcp \
  --header "Authorization: Bearer duck_..."

Claude Desktop and claude.ai

Not yet verified by us. Add a custom connector with the URL https://api.duck.study/mcp, set authentication to “No sign in”, and add the request header Authorization: Bearer duck_.... Duck does not offer OAuth sign-in, so a client that only supports OAuth cannot connect.

Hosts without header support

If a host cannot send a custom header, run the mcp-remote bridge as a local stdio server in its config:

{
  "mcpServers": {
    "duck": {
      "command": "npx",
      "args": ["mcp-remote", "https://api.duck.study/mcp", "--header", "Authorization: Bearer duck_..."]
    }
  }
}

Tools

The server is stateless: every request is a single JSON-RPC POST. GET and DELETE answer 405.

The tool descriptions tell the agent to show a draft before saving it unless you asked it to just add the card, and to add and delete only when you ask. A tool that fails answers with isError: true and a text that starts with the error code from the error table.

To try the server without Claude, list its tools. The Accept header must name both content types:

curl https://api.duck.study/mcp \
  --header "Authorization: Bearer duck_..." \
  --header "Accept: application/json, text/event-stream" \
  --header "Content-Type: application/json" \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Calling a tool is the same request with tools/call; the result is the JSON of the HTTP response, as text:

--data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_cards","arguments":{"due":"today","limit":1}}}'

{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"cards\":[{\"id\":104,\"front\":\"come loose\", ... }],\"next_after\":104}"}],"isError":false}}

Cards

These operations see the cards in your deck. Cards still waiting for you to accept them from a batch, archived cards and deleted cards are invisible and answer 404. Examples below are real responses from the server; the token is shortened to duck_....

List: GET /v1/cards

Query: due=today (only cards due for review now, or in learning; no other value is accepted), q (case-insensitive text in the front), limit (default 50, at most 200), after (the next_after of the previous page). Cards come in id order. next_after is null on the last page.

curl "https://api.duck.study/v1/cards?due=today&limit=1" \
  --header "Authorization: Bearer duck_..."

{
  "cards": [
    {
      "id": 104,
      "front": "come loose",
      "back": "to become detached or free from being stuck",
      "example": null,
      "context": null,
      "source": null,
      "due_at": "2026-10-27T16:17:48Z",
      "reviews": { "count": 16, "lapses": 2, "last_at": "2026-10-25T16:17:48Z" },
      "fsrs": { "stability": 7.349, "difficulty": 9.637 },
      "reverse_of_id": null,
      "content_revision": 0,
      "created_at": "2025-12-07T22:03:06Z",
      "updated_at": "2026-10-25T16:17:48Z"
    }
  ],
  "next_after": 104
}

(Real output, whitespace added.) To read the next page, send after=104.

Get: GET /v1/cards/:id

The same record plus explanation and examples (as stored; nothing is generated on read) and pronunciation_url (or null), placed after source.

curl https://api.duck.study/v1/cards/3255 \
  --header "Authorization: Bearer duck_..."

{
  "id": 3255,
  "front": "hunker down",
  "back": "to hide away and wait for trouble to pass",
  "example": null,
  "context": "And so they hunker down.",
  "source": { "type": "api", "title": "Fargo" },
  "explanation": [],
  "examples": [],
  "pronunciation_url": null,
  "due_at": null,
  ...
}

Create: POST /v1/cards

front and back are required and must not be blank. example, context (the sentence you met the phrase in) and source (a title, for example a book or show) are optional text. Nothing is generated. A free account has a daily limit on new cards; past it the answer is 422 with the reason.

Duplicates. If you already have a card with the same front and the same back (compared without surrounding spaces and ignoring case), Duck does not add another. It answers 200 with the existing card and "created": false. The same front with a different back is a different sense of the word, and is created (201, "created": true). It is safe to repeat a create request.

curl https://api.duck.study/v1/cards \
  --header "Authorization: Bearer duck_..." \
  --header "Content-Type: application/json" \
  --data '{"front":"hunker down","back":"to stay somewhere and wait for a difficult situation to end","example":"They [hunkered down] in the cellar until the storm passed.","context":"And so they hunker down.","source":"Fargo"}'

HTTP 201
{
  "id": 3255,
  "front": "hunker down",
  "back": "to stay somewhere and wait for a difficult situation to end",
  "example": "They [hunkered down] in the cellar until the storm passed.",
  "context": "And so they hunker down.",
  "source": { "type": "api", "title": "Fargo" },
  "due_at": null,
  "reviews": { "count": 0, "lapses": 0, "last_at": null },
  "fsrs": { "stability": 0.0, "difficulty": 0.0 },
  "reverse_of_id": null,
  "content_revision": 0,
  "created_at": "2026-10-27T22:43:36Z",
  "updated_at": "2026-10-27T22:43:36Z",
  "created": true
}

Sending the same body again, or "Hunker down" with the same back, answers 200 with the same record and "created": false.

Update: PATCH /v1/cards/:id

Send any of front, back, example. A key you leave out keeps its value. "example": null removes the example; front and back cannot be cleared. The answer is the updated record.

content_revision is optional. Send the value you last read and the update is refused with 409 if the card changed since, so you never overwrite a change you did not see. Without it, the last writer wins.

curl --request PATCH https://api.duck.study/v1/cards/3255 \
  --header "Authorization: Bearer duck_..." \
  --header "Content-Type: application/json" \
  --data '{"back":"to hide away and wait for trouble to pass","content_revision":0}'

HTTP 200   (back changed; content_revision is now 1)

The same request again, still with "content_revision":0:

HTTP 409
{"error":"stale_revision","message":"This card changed. Reload it and try again."}

curl --request PATCH https://api.duck.study/v1/cards/3255 ... --data '{"example":null}'

HTTP 200   ("example": null)

Delete: DELETE /v1/cards/:id

Answers 204 with no body. The card is then 404 for every operation.

curl --request DELETE https://api.duck.study/v1/cards/3255 \
  --header "Authorization: Bearer duck_..."

HTTP 204

Drafts

POST /v1/drafts

Duck proposes a card for a phrase with AI. Nothing is stored: a draft is saved by sending it to POST /v1/cards. Body: phrase (required, at most 200 characters) and context (the sentence you met it in, at most 1000 characters; the draft uses it to pick the sense the phrase has there). A draft costs an AI call and counts toward your plan’s AI allowance. When the allowance is used up the answer is 402.

The answer has a status. A ready draft is a card to show and save:

curl https://api.duck.study/v1/drafts \
  --header "Authorization: Bearer duck_..." \
  --header "Content-Type: application/json" \
  --data '{"phrase":"hunker down","context":"And so they hunker down.","sense":{"front":"take shelter","back":"stay in a safe place during a storm, danger, or difficult time"}}'

{
  "status": "ready",
  "front": "hunker down",
  "back": "to stay in a safe place for shelter during a storm, danger, or difficult situation",
  "example": "Residents were advised to [hunker down] at home until the hurricane passed."
}

An ambiguous draft means the phrase has several senses. It lists up to four. Ask the learner which one they mean, then send the same request again with sense set to the chosen {front, back} pair, as above.

curl https://api.duck.study/v1/drafts ... --data '{"phrase":"hunker down","context":"And so they hunker down."}'

{
  "status": "ambiguous",
  "phrase": "hunker down",
  "senses": [
    { "front": "take shelter", "back": "stay in a safe place during a storm, danger, or difficult time" },
    { "front": "focus on work", "back": "settle in to work hard on a task with full concentration" },
    { "front": "squat", "back": "crouch or sit low on one's heels" }
  ]
}

The AI’s answer differs from call to call. When the draft is ready, send its front, back and example to POST /v1/cards to save it.

The card record

Fields come in this order: the text you read, then scheduling, then links and bookkeeping. Times are ISO 8601 in UTC, to the second, and null when unset.

id
The card’s id, used in paths and as the after cursor.
front, back
The word or phrase, and its meaning. back can be null on a card that has none yet.
example
One usage sentence with the taught term marked in square brackets, or null.
context
The sentence the phrase was met in, or null.
source
{"type", "title", ...} or null. type is tv_show, movie, chat or api; title, season, episode and year appear when known.
explanation, examples, pronunciation_url
Only in GET /v1/cards/:id. Stored explanation entries and example sentences, and a permanent link to the audio or null.
due_at
When the card is next due. null for a card that was never reviewed.
reviews
count of reviews, lapses (times it was forgotten) and last_at. Read-only: the API cannot add reviews.
fsrs
stability (in days) and difficulty: the scheduler’s memory model of the card. Read-only.
reverse_of_id
The id of the card this one reverses, or null.
content_revision
Counts changes to the text. Send it back on update to detect a conflict.
created_at, updated_at
Bookkeeping times.
created
Only in the answer to create: true for a new card, false for a duplicate.

Text format

Text fields are stored and returned raw. They are never converted to HTML. The app understands four rules in them:

Errors

Every error has the same body: {"error": "code", "message": "text for a person"}.

StatuserrorWhen
401unauthenticatedThe token is missing, unknown or revoked.
402payment_requiredA draft needs AI allowance that your plan has used up.
404not_foundNo card with that id in your deck, or a path the API does not have.
409stale_revisionThe content_revision you sent is out of date.
422invalidA blank or oversize field, a bad due or limit, a malformed body, or the daily limit on new cards.
429rate_limitedToo many requests; see below.
500internalSomething went wrong on our side. The message is generic.

Rate limits

Limits count per learner, not per address, so several tools sharing one network do not share a limit. A request over the limit answers 429; wait a minute.