QuestLog API

Read and write QuestLog projects from your own code. One key, no account, no password.

Early access. The API works and is stable, but it is new and its structure may change. Anyone with a key is told before a change breaks compatibility.

Your board, live

A key is not just for scripts. Paste it into QuestLog itself and your projects open in the browser, on any device.

  1. Open questlog.today
  2. Click the small light in the bottom right
  3. Paste your key

The light stays green while you are connected. A card your code creates appears within a second, outlined green, with nothing to refresh. Open it on five machines and they all follow along. Click the light again to disconnect.

Getting a key. Email us and we send you one. It is free, there is nothing to fill in, and you do not need to explain yourself. Keys are made by hand right now, so a person reads your message. Self service is coming.

What it is

One key, no account, no password. It reaches everything in a project: projects, lists, cards, descriptions, priorities, effort, deadlines, tags, checklists, subtasks and comments.

The website runs on this same API, so a card created from a script shows up on the board straight away. Descriptions take full Markdown, tables and code blocks included, with no practical length limit.

The website works without a key. Projects stay in your browser until you connect one.

A key is shown once and never stored. Give each device or script its own, and revoke a lost one with DELETE /api/v1/keys/:id.

Lose your only key and that account is unreachable. While keys are issued by hand we can sort it out if you email from the address you asked from. That stops working once keys are self service, so make a spare now.

Versions

Use /api/v1/. A later version will not change how v1 answers.

Your first request

Every request sends your key as a bearer token. Get your projects:

curl -H "Authorization: Bearer questlog_your_key_here" \
  https://questlog.today/api/v1/projects

Add a card

Creating a card requires the ID of the list it belongs to. Example:

curl -X POST https://questlog.today/api/v1/tasks \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "columnId": "c_abc123",
    "title": "Ship the thing",
    "description": "Full **markdown** here.\n\nMore notes here",
    "priority": 1,
    "effort": "medium",
    "deadline": "2026-09-01",
    "deadlineTime": "17:00",
    "location": "The pharmacy",
    "parentId": null,
    "tags": ["release"]
  }'

Response:

{
  "ok": true,
  "id": "t_..."
}

You can send your own IDs. That is how the website keeps data stable without rekeying.

Creating a task answers 201 with id and the whole created task under task, shaped exactly as a read of it would be. The task carries rev, the version this account holds for it, so a client knows it without a follow-up read. id is still there on its own for callers that only want that.

location is free text, a note about where. There is no geofence and no arrival reminder behind it: it is a field you fill in and read, and it is trimmed to fit rather than refused if it is long.

parentId makes the card a subtask of another card on the same project. Subtasks go one level deep: a card that is itself a subtask cannot be a parent, and a card with subtasks cannot become one. Breaking either rule answers 422 and names the field. Send null to lift a card back to the top level.

Deleting a parent deletes its subtasks with it, in the same request. Completing one does not: if you want the website’s behavior, where finishing a parent finishes its open subtasks, send those completedAt values yourself.

Moving a parent to another list does not move its subtasks, because columnId is written one card at a time. The website moves the whole family, and any client that reads the board will pull a stray subtask into its parent’s list. Move them together if you want the board to look the way you left it.

Add a list

A card needs a list to live in. Lists are called columns in the API, which is what the board draws them as:

curl -X POST https://questlog.today/api/v1/columns \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"projectId": "b_abc123", "name": "Bugs"}'

The reply carries the new id, which is the columnId a card is created with.

Update a card

curl -X PATCH https://questlog.today/api/v1/tasks/t_abc123 \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"priority": 2, "completedAt": null}'

Set up a project with one prompt

If you use Claude Code or a similar agent, you can wire a whole project up to QuestLog in one step. Paste your key into the prompt below, then paste the whole thing into a session opened in your project folder. When it finishes, your work list is a live board here, your agent reads it at the start of every session, and anything you change on your phone reaches the next session automatically.

Set up this project to track its work on QuestLog instead of a TODO file.
My API key is: PASTE_YOUR_KEY_HERE

1. Store the key in a file this repo can never publish. Outside the repo
   is best. If it has to live inside, add it to .gitignore first and prove
   the ignore works before writing the key. The key never goes in a
   commit, a doc, or any file that deploys.

2. Fetch my account with GET https://questlog.today/api/v1/projects and
   the header "Authorization: Bearer <key>". If there is no board for this
   project, create one with POST /api/v1/projects, named in PascalCase
   with no spaces. Give it three columns named for the kind of work:
   Research for questions nobody has answered, Implement for agreed work,
   Bugs for broken things. There is no Done column. A completed card stays
   in its column, so the counts always show what is left.

3. If this project has a TODO file with real items, move them all onto the
   board. Open items become tasks: POST /api/v1/tasks with columnId,
   title, description, priority 1 to 4, and tags. Finished items get
   completedAt on the same call, so the history survives. Rewrite each
   item clean rather than copying it word for word: short Title Case title
   with no trailing period, and a first description line that says what
   the thing IS, because that line is the card's preview on the board.
   Keep any standing instructions inside the cards they belong to. Tag
   anything only I can do (a payment, a login, a decision) with #you, and
   never sit waiting on one of those.

4. Replace the TODO file with a short stub that names the board, says the
   work now lives there, and forbids ever writing work into this file
   again. Two lists that both look real is the one failure this setup
   cannot survive.

5. Add a section to this project's CLAUDE.md (or your instructions file):
   fetch the board before planning anything, every session, because
   nothing loads it for you. Complete a card the moment its work is done:
   PATCH /api/v1/tasks/:id with completedAt. Add a card the moment new
   work appears. Re-read before writing, because I edit the same board
   from my phone while you work.

6. Prove the loop before you finish: fetch the board and show me the open
   cards, complete a test card, un-complete it, then delete it.

The API documents itself: GET https://questlog.today/api/v1 with the key.

This is the setup we run QuestLog's own development on. Every step in it comes from something that went wrong without it.

Responses

Reads return camelCase. Writes take camelCase or snake_case. A full account comes from GET /api/v1/projects, with no pagination, because projects are meant to stay small. To poll for changes, use Catching up below instead: it is about a hundred times smaller.

If you only care about one project, ask for it by id. GET /api/v1/projects/:id answers in exactly the same shape, with a single entry in projects, so the same code reads either one. An id that is not yours returns 404, the same as any other project route.

curl https://questlog.today/api/v1/projects/b_abc123 \
  -H "Authorization: Bearer $QUESTLOG_KEY"

If you know the name rather than the id, filter the list instead. ?name= folds case and can return more than one project, because duplicate names are allowed. A name that matches nothing is an empty list and a 200, not an error.

curl "https://questlog.today/api/v1/projects?name=Roadmap" \
  -H "Authorization: Bearer $QUESTLOG_KEY"

Both are worth reaching for. A named read stays the size of the one board you asked about.

A read answers with open cards only. Finished cards are never deleted, so an account that includes them grows forever and most callers never look at them. Add ?completed=all when you do want the history, ?completed=recent for open cards plus anything finished in the last 30 days, or ?completed=none to say the default out loud. Any other value is refused rather than ignored, so a typo cannot quietly hand you a partial account that looks complete.

curl "https://questlog.today/api/v1/projects?name=Roadmap&completed=all" \
  -H "Authorization: Bearer $QUESTLOG_KEY"

GET /api/v1/export is unaffected and always carries everything, because an export that quietly dropped your finished work would not be a backup.

Errors always return:

{
  "ok": false,
  "error": "..."
}

An unknown field is refused rather than ignored, and the error names it and lists what the endpoint does accept, so a typo never passes as a request that quietly did nothing.

Everything is checked before anything is written, and a multi row write lands whole or not at all. A 4xx never leaves a card half updated.

Catching up

Reading a whole account to find one changed card gets expensive as the account grows. Ask for only what moved instead:

GET /api/v1/changes?since=1786000000000

Back comes every project, list and card that changed since that moment, in the same shape a full read uses, plus two things:

Catching up carries open cards plus the last 30 days of finished ones. That is what a board actually looks at, and it is most of the cost: on the account this was measured against, 86% of every catch-up was finished work nobody had open. Send ?completed=all for every finished card ever, or ?completed=none for open cards only. The reply always says which you got in window, so nothing has to infer it. Nothing is deleted either way: a card that falls out of the window is still there under /projects?completed=all.

That id list is what makes the answer exact. A card can vanish by being deleted, by falling out of the recycle bin, or by being purged, and the list covers all three without you knowing which.

A deleted project or list comes back carrying deletedAt. Drop it and everything under it.

The id list is every live card on the account, which gets expensive. Send ?manifest=changed and it covers only the projects in the reply instead, and the reply carries scoped: true so you know which you got. That is safe because a card can only leave in two ways. A write always bumps its project's clock, so the project is in the reply and the card is still reported by being absent from the narrow list. The other way is ageing out of the 30 day window, which no write touches at all: drop anything you hold whose completedAt is before the cutoff in the reply, and the two of you agree again.

Only send it if you do that. The default is the whole list, because a client that treats a narrow one as the complete truth deletes most of its own copy.

Leave since off to get everything, which is what a client opening for the first time wants. The comparison is inclusive, so a card written in the same millisecond as your last now is never skipped. You may occasionally see a card you already have, which costs nothing, because every card carries rev.

Tell it what you already hold and an idle catch-up gets much smaller:

GET /api/v1/changes?since=1786000000000&holding=2053

Count it from the previous taskIds, not from a /projects read. The manifest covers whichever window you asked for, while a project read is open cards only unless you say otherwise. Counting the wrong set is harmless but useless: the numbers never match, so you get the full reply every time instead of the short one.

holding is the number of live cards you have across the whole account, not one project. When nothing has moved and that number matches the server’s, the reply comes back as {"unchanged": true} with no taskIds list at all, because the list is almost the entire cost of asking. You still get now. Leave the parameter off and you get the full reply, byte for byte, so nothing breaks by ignoring it. A value that is not a whole number answers 422 rather than being treated as absent.

On a 300 card account measured here, a full read is 490KB and an idle catch-up is 4KB. On a 2,053 card account the same idle catch-up went from 21,551 bytes to 96 with holding.

This is not a replacement for the socket. The socket says the moment something changed. This says what you missed. Together, a connected client waits for the nudge, then fetches only the difference.

Multiple writers

Writable fields take guarded updates:

{
  "priority": {
    "before": 3,
    "after": 1
  }
}

The write lands only while the field still holds the expected value. If somebody got there first you get a 409 with the current value and nothing is overwritten. The check is per field, so two clients editing different fields of the same card never collide.

Undo

Undo the latest change made by your key:

POST /api/v1/undo

It only touches your own changes, only works while the current value still matches, and reaches back a limited number of steps, listed with the other limits below.

It is not the only way back. A deleted card can be restored by setting cancelledAt to null, however long ago it went.

Dates and times

deadline is a plain day, 2026-09-01. deadlineTime is a time of day beside it, 17:00, 24 hour:

curl -X PATCH https://questlog.today/api/v1/tasks/t_abc123 \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"deadline": "2026-09-01", "deadlineTime": "17:00"}'

The two are independent. Either can be set without the other, clearing one leaves the other alone, and a card can carry a time with no day at all, which is how someone writes down "5:30" without deciding which day they mean. Send null to clear either.

deadlineTime carries no time zone, deliberately. It is read beside a day that already has one, and a zone here would mean the same card read differently in two places. A time that is not HH:MM on a 24 hour clock answers 422 rather than being quietly dropped, so 9:30 is refused where 09:30 is accepted.

A card carrying only a time has no day, so it sorts and filters with the undated ones rather than being treated as today. Sorting by date uses the time as a tiebreaker inside a day, and a card with no time comes before one with a time on the same day.

Ordering

Every project, list and card carries a position, and reads come back sorted by it. It is a decimal on purpose: to put a card between two others, send a number between theirs. Nothing else has to be renumbered, so inserting one card is one request rather than one per card below it.

curl -X PATCH https://questlog.today/api/v1/tasks/t_abc123 \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"position": 1536.5}'

Leave it off when you create something and it lands at the top of its list. Gaps between positions mean nothing, only the sequence does, so you never need to keep them tidy. Checklist items work the same way: POST /api/v1/tasks/:id/checklist takes a position and shifts the items after it down, which is how a line gets inserted mid-list instead of appended.

Creating many at once works the same way. Send tasks instead of a single body, with up to 200 entries, each exactly as you would send it on its own:

curl -X POST https://questlog.today/api/v1/tasks \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"tasks": [{"columnId": "c_abc123", "title": "First"}, {"columnId": "c_abc123", "title": "Second"}]}'

It answers {"ok": true, "created": 2, "rev": 1, "ids": [...]}, with the ids in the order you sent them. The batch is all or nothing: one bad entry refuses the whole thing, names the line that was wrong, and writes none of it. Entries land above what is already in the list, in the order sent, unless they carry their own position. Unlike the single form it does not answer with the shaped cards, because that would cost four queries each.

A checklist takes the same treatment with items, which appends them in order:

curl -X POST https://questlog.today/api/v1/tasks/t_abc123/checklist \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"text": "Wash"}, {"text": "Dry", "done": true}]}'

Moving many cards at once, the way a one-click sort does, goes in a single request rather than one per card:

curl -X PATCH https://questlog.today/api/v1/tasks/positions \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"positions": [{"id": "t_abc123", "position": 10}, {"id": "t_def456", "position": 20}]}'

It answers {"ok": true, "changed": true, "moved": 2}, counting only the cards that actually moved. Up to 1000 in one request, they can span different lists and projects, and a position is any number, decimals included. An id your account does not hold is ignored rather than refused, so a card deleted on another device mid-sort does not lose the whole gesture, and a card that is already where you asked for costs nothing. Positions sent this way are not guarded and cannot be undone through POST /api/v1/undo, for the same reason a checklist reorder cannot: ordering is last-writer-wins and belongs to the whole set.

To rearrange a checklist that already exists, send the whole thing in the order it should read:

curl -X POST https://questlog.today/api/v1/tasks/t_abc123/checklist/order \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"ids": ["k_third", "k_first", "k_second"]}'

One request for the whole list, and sending the same order twice changes nothing the second time. Any item you leave out keeps its place at the end, so a partial list taken off a filtered view is safe: nothing is dropped. An id this card has never held is ignored rather than refused, because the usual reason for one is an item deleted elsewhere while you were rearranging. A reorder cannot be undone through POST /api/v1/undo: undo puts one field of one record back to a value it recorded, and an order belongs to the whole list rather than to any one line in it. Send the order you want instead.

Finishing a card finishes its subtasks: send completedAt on a parent and every subtask still open takes the same timestamp, so the family reads as done together. Subtasks that were already finished keep their own times, and clearing completedAt on the parent reopens the parent alone. Deleting works the same way and always has.

Archiving

A project or a list can be put away without being destroyed. Send a millisecond timestamp as archivedAt, or null to bring it back:

curl -X PATCH https://questlog.today/api/v1/projects/b_abc123 \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"archivedAt": 1786000000000}'

Nothing moves and nothing is deleted. The project keeps its lists and every card, the list keeps every card, and clearing the field restores exactly what was there rather than rebuilding it.

Reads still return archived things, carrying the timestamp. That is deliberate: hiding them is a decision for whatever is showing them, and an API client that filtered them out of its own reads could never offer a way back. Filter on archivedAt yourself. This is the opposite of a delete, which does leave normal reads.

Pinning a project

A project carries pinnedAt the same way, a millisecond timestamp or null. It belongs to the account rather than to a browser, so a project pinned from a script or on one device is pinned everywhere you open QuestLog.

curl -X PATCH https://questlog.today/api/v1/projects/b_abc123 \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"pinnedAt": 1786000000000}'

It is a timestamp rather than a flag on purpose: the website sorts pinned projects by when they were pinned, newest first, so the one you reached for most recently leads. Nothing filters on it, so a client that ignores it sees every project exactly as before.

Project photo

A project can carry photo, a small square image sent as a base64 data URL such as data:image/webp;base64,.... WebP, JPEG and PNG are accepted, up to 24,576 characters, which is plenty for a 128 pixel square. The website crops it into a circle beside the project's name, and shows the first letter of the name on a colored circle when there is none.

curl -X PATCH https://questlog.today/api/v1/projects/b_abc123 \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"photo": null}'

Send null to remove it. Anything that is not an image data URL, or is over the limit, is refused with a 422 and nothing is saved.

Deleting data

Deleting is a recycle bin of a fixed size, not a countdown. A delete:

Bring one back while it is still there:

curl -X PATCH https://questlog.today/api/v1/tasks/t_abc123 \
  -H "Authorization: Bearer questlog_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"cancelledAt": null}'

Deleting is also how space is freed. Anything pushed out of the window is destroyed there and then, so an account never waits to become writable again. How many deletions are kept is listed with the other limits below.

Check usage with GET /api/v1/usage. Destroy everything currently deleted, including what is still recoverable, with POST /api/v1/purge.

Endpoints

Read live from the API, so this is what the server is actually running.

Loading...

Limits

Also read live. They sit above normal usage and exist to protect the public API.

Loading...

Short fields like titles are trimmed to fit. Long text fields are rejected with a 422 and the previous value stands.

Errors

CodeMeaning
400The request body was not valid JSON. Nothing was saved.
401The key is missing, invalid, or revoked.
403No Authorization header was sent at all, so the request was refused before reaching the API. This one answers HTML rather than JSON. Send the header and a key problem becomes a normal 401.
404The item does not exist or does not belong to you.
409Someone changed it first. The current value is returned.
413The account is full. Reads and deletes still work.
422A value is invalid or exceeds a limit.
429The key has reached today's allowance.

Exporting your data

Export the whole account:

GET /api/v1/export

The file imports straight back into QuestLog in a browser, with no server and no key, so leaving is easy. Every field a read returns can be sent back on a create, completedAt included, so a finished card is still finished when it lands somewhere else.