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.
list_cards: list your cards, optionally only those due now or matching a text, one page at a time.get_card: one card with its stored explanation, examples and pronunciation link.draft_card: make a card draft with AI from a phrase. Stores nothing.add_card: save a card.update_card: change a card’s front, back or example.delete_card: delete a card.
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
aftercursor. front,back- The word or phrase, and its meaning.
backcan benullon 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", ...}ornull.typeistv_show,movie,chatorapi;title,season,episodeandyearappear 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 ornull. due_at- When the card is next due.
nullfor a card that was never reviewed. reviewscountof reviews,lapses(times it was forgotten) andlast_at. Read-only: the API cannot add reviews.fsrsstability(in days) anddifficulty: 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:
truefor a new card,falsefor a duplicate.
Text format
Text fields are stored and returned raw. They are never converted to HTML. The app understands four rules in them:
- A blank line starts a new paragraph.
**bold**is bold._italic_is italic.- In
example,[term]marks the taught word or phrase, in the form it takes in the sentence:They [hunkered down] in the cellar.The brackets are not shown; the term is highlighted.
Errors
Every error has the same body: {"error": "code", "message": "text for a person"}.
| Status | error | When |
|---|---|---|
| 401 | unauthenticated | The token is missing, unknown or revoked. |
| 402 | payment_required | A draft needs AI allowance that your plan has used up. |
| 404 | not_found | No card with that id in your deck, or a path the API does not have. |
| 409 | stale_revision | The content_revision you sent is out of date. |
| 422 | invalid | A blank or oversize field, a bad due or limit, a malformed body, or the daily limit on new cards. |
| 429 | rate_limited | Too many requests; see below. |
| 500 | internal | Something 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.
- Drafts: 10 per minute, counted across HTTP and MCP together.
- Creating and updating cards over HTTP: 60 per minute together.
- MCP: 60 requests per minute.