Programmatic access to your cheats, Tasks, Brain, Pennies, Projects, Hours and Dues, for external tools acting on your behalf (e.g. a local Claude Code project). Every account can generate a key from the User page's API Access section. Free accounts get read-only access; Pro unlocks write access (create/update/delete) plus a much higher daily limit. See Billing for pricing.
This page covers the REST API itself. If you just want to point Claude Code, Claude Desktop, or another MCP client at Cheatsheet, see Connect an MCP Client instead.
On this page:
Send your key as a bearer token on every request:
Authorization: Bearer csk_live_...
Keys are shown to you only once, at creation, and can be revoked at any time from the User page. Each key is scoped to either read or read + write, and a write-scoped key can also do everything a read-scoped key can.
A key can also be limited to particular sections of the app: Cheats, Tasks, Brain, Pennies, Projects, Hours and Dues, ticked when you create the key on the User page. Scopes decide what a key can do; sections decide what it can reach, and the two apply independently. A key left with everything ticked reaches everything, which is also how every key created before this existed behaves.
This is worth using. A key wired into an unrelated project to look things up in your cheats has no reason to be able to read your Pennies log or your Brain, and until you untick those it can. An endpoint outside a key's sections returns 403 with a message naming the section. A connected MCP client is also offered only the tools for the sections its key covers, so a narrower key means a shorter tool list.
Keys issued through the sign-in flow for an MCP client (rather than created by hand on the User page) always reach every section, because that flow gives the client no way to ask for part of the app. Revoke the key if you want to take that access back.
If you're building your own integration, you can optionally send an X-Cheatsheet-Client header identifying it — a short name of your choosing (letters, numbers, -, _, up to 40 characters), e.g.:
X-Cheatsheet-Client: my-cool-plugin
This is purely so the User page's API usage summary can show a per-client breakdown of a key's daily requests, and so "last used" for a key shows which client it was. It's entirely optional — a request that omits it works exactly the same, just grouped under "unknown" (or, for a direct "type": "http" MCP Connector entry with no header set, "mcp-connector-direct"). Applies the same way to the MCP Connector's headers as it does to the REST endpoints' request headers.
https://cheats.aarontrotter.com
The endpoints below all live under /mcp/, e.g. /mcp/search — that's just this API's URL prefix, not the MCP protocol. An actual MCP client instead connects at the bare path /mcp (no trailing segment) under this same base URL, and doesn't call the endpoints below directly — see Connect an MCP Client.
429. Free: 50 requests/day, read-only. Pro: 500 requests/day fair use, full read + write.add/update/delete, plus Pennies' fill/void and Projects' move) require the calling account to be Pro; a Free account's key returns 403 on those, but can still use every read endpoint.Errors are returned as { "error": "message" } with a matching HTTP status: 400 bad request, 401 missing/invalid key, 403 forbidden (wrong scope, outside the key's sections, not Pro, not the owner), 404 not found, 429 rate limited, 500 server error.
Search your visible cheats — public cheats plus your own (and any linked account's) private ones.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | No | Search text; omitted means the most recently created cheats |
type | string | No | Restrict results to one type/category name |
limit | number | No | Results per page — default 8, max 25 |
page | number | No | 0-indexed page number, default 0 — use with hasMore to fetch further pages |
Returns { results: [{ id, title, type, snippet, private, stars, points }], total, page, hasMore }. snippet is the cheat body truncated to 400 characters. total is the number of cheats matching the search across all pages; hasMore is true when a further page exists — request it with page + 1.
GET /mcp/search?q=docker&limit=5&page=1 Authorization: Bearer csk_live_...
Fetch the full body of one cheat.
Scope: read
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
id | query string | string | Yes | The cheat id, as returned by /mcp/search |
Returns { id, title, type, body: { text, highlights, links }, markup, private, stars, points, ownStars, ownPoints }. markup is the same body as tagged text (see Cheat body markup), which can be edited and sent straight back as body.text to /mcp/updateCheat without losing any formatting. stars and points are cumulative across the cheat's whole revision history, so a later revision starts with every vote cast before it already counted. ownStars and ownPoints are the votes cast on this revision alone, which is the figure to compare when you want to know which version people actually preferred.
404 if the cheat doesn't exist; 403 if it's private and you're not its owner (or a linked account).
GET /mcp/getCheat?id=abc123 Authorization: Bearer csk_live_...
List the full append-only revision history for a cheat's lineage — every past and current version, oldest first.
Scope: read
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
id | query string | string | Yes | Id of any revision in the lineage — not just the original |
Returns { originalId, revisions: [{ id, title, type, snippet, private, ceased, createdDateMs, stars, points, ownStars, ownPoints }] }. Exactly one entry has ceased: false — that's the current version; the others are prior edits kept for history. Rank revisions by ownPoints rather than points: the latter is cumulative down the list, so sorting by it would mostly just tell you which revision is newest.
404 if the cheat doesn't exist; 403 if it's private and you're not its owner (or a linked account).
GET /mcp/getRevisions?id=abc123 Authorization: Bearer csk_live_...
A cheat's notes, variables and links are coloured decorations in the editor, stored as character offsets. Rather than counting offsets, /mcp/addCheat and /mcp/updateCheat read tags out of body.text and turn them into the same decorations:
| Write | Becomes |
|---|---|
[note]Add a user[/note] | A note, for headings and short explanations |
[var]UserName[/var] | A variable, for a placeholder the reader substitutes |
[link=https://example.com]Docs[/link] | A link labelled "Docs"; an href with no scheme gets https:// |
https://example.com | A link to itself, with no tag needed |
value //short comment | A note over the // comment to the end of the line, with no tag needed |
A tag without its partner is left in the text as written. A link may overlap a note or a variable.
"body": { "text": "[note]Add a user[/note]\nuseradd -m [var]UserName[/var] //creates the home dir" }
Create a new cheat. Starts with 0 stars and 0 points regardless of what's sent.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Max 40 characters |
typeName | string | Yes | Max 15 characters, case-insensitive; created automatically if it doesn't already exist |
body.text | string | Yes | Max 100 lines, 600 characters per line; whole record capped at 10 KB. Read as markup unless body.highlights or body.links is also sent |
body.highlights, body.links | array | No | Raw editor offsets, [{ from, to, className }] and [{ from, to, href }], as /mcp/getCheat returns them. Sending either turns markup off, and body.text is stored exactly as sent |
private | boolean | Yes | Must be sent explicitly — there is no default |
Returns { id } — the new cheat's id.
POST /mcp/addCheat
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"title": "Restart nginx",
"typeName": "linux",
"body": { "text": "sudo systemctl restart nginx" },
"private": true
}
Revise a cheat you own. This is append-only, same as editing a cheat in the browser: the old revision is marked ceased and a new one is created and linked to it, nothing is overwritten in place.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the cheat to revise |
title | string | Yes | Max 40 characters |
typeName | string | Yes | Max 15 characters, case-insensitive; created automatically if it doesn't already exist |
body.text | string | Yes | Max 100 lines, 600 characters per line; whole record capped at 10 KB. Read as markup unless body.highlights or body.links is also sent |
body.highlights, body.links | array | No | Raw editor offsets, [{ from, to, className }] and [{ from, to, href }], as /mcp/getCheat returns them. Sending either turns markup off, and body.text is stored exactly as sent |
private | boolean | Yes | Must be sent explicitly — there is no default |
Returns { id } — the new revision's id (different from the id you sent in). 404 if the original doesn't exist; 403 if you don't own it.
POST /mcp/updateCheat
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123",
"title": "Restart nginx",
"typeName": "linux",
"body": { "text": "sudo systemctl restart nginx --now" },
"private": true
}
Delete a cheat you own. Not a hard delete — same as the browser's delete button, the cheat is marked ceased and stops appearing in search/getCheat, but the record and its revision history aren't erased.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the cheat to delete |
Returns { success: true }. 404 if the cheat doesn't exist; 403 if you don't own it.
POST /mcp/deleteCheat
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123"
}
Tasks are unencrypted, private-only notes that live outside cheat search entirely, in a separate collection with its own endpoints below. Unlike cheats they have no type taxonomy (just a fixed category) and no private field (always private), and no revision history is ever exposed, even though an edit is append-only under the hood. A task is either permanent or auto-expires and is then automatically deleted (soft-ceased).
These endpoints cover your Tasks page only, meaning the note and list categories. Your Brain is a separate section with its own endpoints below, and nothing here reaches it: an id belonging to a Brain entry is rejected with a 404 rather than quietly working. /mcp/getGuides is the one exception, and it is read-only.
Fetch your brief and rules entries as one formatted block of text, ready to hand straight to an assistant. This is the same text the hosted MCP connector sends automatically when a client connects, exposed here so a tool built on the REST API can fetch it too. Takes no parameters.
Scope: read
Returns { guides }, a single Markdown string with a heading per category and one bullet per entry. guides is an empty string if you have no brief or rules entries.
GET /mcp/getGuides Authorization: Bearer csk_live_...
Search your own tasks. Not Algolia-backed like cheat search, just a plain title/text/category substring match over your tasks, since a personal task list is small.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | No | Search text matched against title/text/category; omitted means the most recently created tasks |
category | string | No | note or list, to search just that one. Omitted means both |
page | number | No | 1-indexed page number, default 1 |
Returns { tasks: [{ id, title, category, text, highlights, links, checkedLines, createdDateMs, expiresAtMs }], totalPages }. expiresAtMs is null for a permanent task; category is note or list. 400 if category isn't one of those two.
GET /mcp/searchTasks?q=groceries Authorization: Bearer csk_live_...
Fetch one task.
Scope: read
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
id | query string | string | Yes | The task id, as returned by /mcp/searchTasks |
Returns { id, title, category, text, highlights, links, checkedLines, createdDateMs, expiresAtMs }. 404 if the task doesn't exist or is a Brain entry; 403 if you don't own it.
GET /mcp/getTask?id=abc123 Authorization: Bearer csk_live_...
Create a new task.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Max 40 characters |
category | string | No | note or list; defaults to note. A Brain category here is a 400; use /mcp/addBrain instead |
text | string | Yes | Max 100 lines, 600 characters per line; whole record capped at 10 KB |
duration | string | No | One of permanent, 1h, 1d, 1w; defaults to permanent |
Returns { id }, the new task's id.
POST /mcp/addTask
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"title": "Ship the release",
"category": "list",
"text": "1. Tag the branch\n2. Deploy\n3. Announce",
"duration": "1d"
}
Revise a task you own. Append-only, same as /mcp/updateCheat (the old revision is ceased and a new one created), but there is no endpoint to view that history for Tasks.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the task to revise |
title | string | Yes | Max 40 characters |
category | string | No | note or list. Omitted keeps the task's current category |
text | string | Yes | Max 100 lines, 600 characters per line; whole record capped at 10 KB |
duration | string | No | One of permanent, 1h, 1d, 1w; defaults to permanent |
Returns { id }, the new revision's id. 404 if the original doesn't exist or is a Brain entry; 403 if you don't own it.
POST /mcp/updateTask
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123",
"title": "Ship the release",
"category": "list",
"text": "1. Tag the branch\n2. Deploy\n3. Announce\n4. Watch dashboards",
"duration": "1d"
}
Delete a task you own. Not a hard delete, same soft-cease as /mcp/deleteCheat.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the task to delete |
Returns { success: true }. 404 if the task doesn't exist or is a Brain entry; 403 if you don't own it.
POST /mcp/deleteTask
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123"
}
Your Brain is where an assistant's working context lives: brief entries are orienting context you write for it, rules are constraints it must follow, and memory is what it records for itself between sessions. Your brief and rules are also handed to any MCP client automatically when it connects, so it reads your guidance before doing anything.
Brain and Tasks are separate sections here, not two filters over one list. The five endpoints below reach your Brain and nothing else, exactly as the five above reach your Tasks and nothing else. An id addressed through the wrong section is rejected with a 404, so deleting a memory can never quietly delete a shopping list instead, and an assistant tidying up your notes can never touch the rules you wrote for it.
Search your Brain entries. Same plain substring match over title/text/category as /mcp/searchTasks, restricted to your brief, rules and memory entries.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | No | Search text matched against title/text/category; omitted means the most recently created entries |
category | string | No | One of brief, rules, memory, to search just that one. Omitted means all three |
page | number | No | 1-indexed page number, default 1 |
Returns the same shape as /mcp/searchTasks: { tasks: [{ id, title, category, text, highlights, links, createdDateMs, expiresAtMs }], totalPages }. 400 if category isn't one of the three.
GET /mcp/searchBrain?category=memory Authorization: Bearer csk_live_...
Fetch one Brain entry.
Scope: read
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
id | query string | string | Yes | The entry id, as returned by /mcp/searchBrain |
Returns { id, title, category, text, highlights, links, createdDateMs, expiresAtMs }. 404 if it doesn't exist or is a task rather than a Brain entry; 403 if you don't own it.
GET /mcp/getBrain?id=abc123 Authorization: Bearer csk_live_...
Record a new Brain entry.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Max 40 characters |
category | string | No | One of brief, rules, memory; defaults to memory, the category an assistant writes for itself |
text | string | Yes | Max 100 lines, 600 characters per line; whole record capped at 10 KB |
duration | string | No | One of permanent, 1h, 1d, 1w; defaults to permanent |
Returns { id }, the new entry's id.
POST /mcp/addBrain
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"title": "Deploy target",
"category": "memory",
"text": "This project deploys to europe-west3, not us-central1."
}
Revise a Brain entry you own. Append-only, same as /mcp/updateTask, and with no history endpoint either.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the entry to revise |
title | string | Yes | Max 40 characters |
category | string | No | One of brief, rules, memory. Omitted keeps the entry's current category, so an edit never moves it out of your Brain |
text | string | Yes | Max 100 lines, 600 characters per line; whole record capped at 10 KB |
duration | string | No | One of permanent, 1h, 1d, 1w; defaults to permanent |
Returns { id }, the new revision's id. 404 if the entry doesn't exist or is a task; 403 if you don't own it.
POST /mcp/updateBrain
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123",
"title": "Deploy target",
"text": "This project deploys to europe-west3. Region is set in functions/index.js."
}
Delete a Brain entry you own. Not a hard delete, same soft-cease as /mcp/deleteTask.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the entry to delete |
Returns { success: true }. 404 if the entry doesn't exist or is a task; 403 if you don't own it.
POST /mcp/deleteBrain
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123"
}
Pennies is your own private log of investment orders: crypto, stocks, ETFs, bonds, commodities and CFDs, with a portfolio summary built from them. Like Tasks, it is private-only, never appears in cheat search, and has no revision history. Besides logging an order, you can correct one, void it and, for a limit order, confirm it filled.
These endpoints were deliberately left out of this API while Pennies existed only in the browser, on the grounds that a financial log should not be reachable by a key at all. They are here now on the same terms as everything else: a read-only key can read your orders but cannot change them, so issuing read-only keys is still how you keep this section untouchable by an integration.
List your own logged orders, newest first. Not a text search, since an order has no body to match on, just the same type and asset filters the Orders tab uses.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
itemType | string | No | One of crypto, stock, etf, bond, commodity, cfd, other |
assetName | string | No | Restrict to one asset, for example BTC. Applied within itemType when both are given |
page | number | No | 1-indexed page number, default 1 |
Returns { pennies: [{ id, side, itemType, orderKind, status, assetName, platform, currency, quantity, pricePerUnit, rewardQuantity, fees, tax, date, notes, walletId, createdDateMs, filledDateMs }], totalPages, filters }. side is one of buy, sell, stake, unstake, reward; the last three are crypto only.
GET /mcp/searchPennies?itemType=crypto&assetName=BTC Authorization: Bearer csk_live_...
Your portfolio, derived from those orders. One holding per asset, with cost basis on the moving-average method, so each sell is measured against the average cost at the moment it happened. If you have set a base currency on your user page, every figure also comes back converted into it, with historical costs converted at the rate on each order's own trade date.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
taxYear | number | No | Restrict to one tax year, named by the calendar year it starts in. Boundaries follow your country, so a UK tax year runs 6 April to 5 April. Holdings come back as they stood at that year end, while realized profit, staking income and fees cover only that year |
Returns { holdings: [{ itemType, assetName, currency, currencies, remainingQty, stakedQty, rewardQty, avgCostPerUnit, costBasisRemaining, realizedPL, feesPaid, netCashFlow, ledger }], baseCurrency, taxYears, taxYear, reconciliation }. reconciliation pairs each asset's logged quantity against a balance you have recorded by hand, as { itemType, assetName, loggedQty, actualQty, driftQty, updatedAtMs }, and is null when a taxYear is applied. A holding traded in more than one currency reports currency: null and returns only the base-currency figures. A negative remainingQty means orders are missing from the log.
GET /mcp/getPennySummary?taxYear=2025 Authorization: Bearer csk_live_...
Log an order.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
side | string | Yes | buy or sell for a trade. stake, unstake and reward are crypto only |
itemType | string | Yes | The instrument type |
assetName | string | Yes | Ticker or short name, max 20 chars |
currency | string | Yes | ISO 4217 code the order settled in |
quantity | number | Yes | Units, greater than 0 |
pricePerUnit | number | For trades | Price per unit. Optional and normally 0 for stake and unstake, except an unstake sent with alsoSell, where it is the sell price. On a reward it is the optional value at receipt, reported as staking income and never treated as cost |
orderKind | string | No | market (default) or limit. A limit order starts pending and stays out of the summary until confirmed |
rewardQuantity | number | No | Unstake only: extra units the staking product paid out |
platform | string | No | Max 30 chars |
walletId | string | No | Crypto only, max 100 chars. Dropped for other types |
fees / tax | number | No | Totals for this order, default 0 |
date | number | No | Trade date in milliseconds. Backdate it to the real date, which is what the currency conversion uses |
notes | string | No | Max 200 chars |
alsoStake | boolean | No | Crypto market buy only. Also logs a stake of the same units, for platforms that buy the coin as you stake it. Fees and tax go on the buy |
alsoSell | boolean | No | unstake only. Also logs a market sell of everything the unstake released (quantity plus rewardQuantity) at pricePerUnit. Fees and tax go on the sell |
Returns { id }, or { id, stakeId } / { id, sellId } when alsoStake / alsoSell wrote a pair. Both orders of a pair are saved together or not at all. The Free-tier order cap does not apply here, since write access already requires Pro.
POST /mcp/addPenny
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"side": "buy",
"itemType": "crypto",
"assetName": "BTC",
"currency": "GBP",
"quantity": 0.05,
"pricePerUnit": 48000,
"fees": 1.5,
"platform": "Revolut",
"date": 1767225600000
}
Correct an order you own. Send only the fields that change: anything you leave out keeps the value logged. Any field /mcp/addPenny takes can be changed, apart from alsoStake and alsoSell. The edit is saved as a new revision, so the order comes back with a new id, though no revision history is exposed. A limit order already confirmed filled stays filled.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the order to correct |
Any /mcp/addPenny field | No | The new value. Send an empty string to clear platform, walletId or notes |
Returns the revised order, in the same shape as an entry in /mcp/searchPennies, with its new id. 404 if the order doesn't exist; 403 if you don't own it; 409 if it has already been edited or voided, in which case search again for the current version.
POST /mcp/updatePenny
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123",
"fees": 2.25
}
Confirm that a pending limit order executed, which is what lets it count toward your portfolio. Anything you omit keeps the value originally logged.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the pending order |
pricePerUnit | number | No | The actual fill price, if it differed from the target |
fees / tax | number | No | Actual amounts, if they differed |
date | number | No | Actual execution date in milliseconds |
Returns { success: true }. 400 if the order is not pending; 404 if it doesn't exist; 403 if you don't own it.
POST /mcp/fillPenny
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123",
"pricePerUnit": 47850
}
Void an order you own, or cancel one still pending. Not a hard delete, same soft-cease as /mcp/deleteCheat, but it is the only way an order is ever removed and it cannot be undone from the API.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the order to void |
Returns { success: true }. 404 if the order doesn't exist; 403 if you don't own it.
POST /mcp/voidPenny
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123"
}
Projects is your Kanban board: projects are the boards, and each card belongs to one of them and carries the status that decides its column. Like Tasks it never appears in cheat search and has no revision history, even though editing a card is append-only underneath. There is no done column: a finished card is deleted and leaves the board.
Unlike every other section here, a project can be shared with other people, so these endpoints are not limited to boards you created. Boards shared with you come back from /mcp/searchProjects alongside your own, each with a role saying what you may do on it: owner, editor (add, edit, move and delete cards, including ones other people added) or viewer (read-only, so the write endpoints return 403).
Four things the browser can do that these endpoints deliberately can't. Renaming a project has no integration asking for it. Deleting a whole project deletes every card on it at once, which is too much reach for a call that can be made by mistake, so cards are deleted one at a time here. Card sharing hands a card to people outside your account, which belongs on the page where you can see the allow-list. And inviting someone to a project emails a person a link to a whole board, which is not something an API key should be able to do on your behalf, so invites and members are managed on the Projects page only.
List your projects. Takes no parameters.
Scope: read
Returns { projects: [{ id, name, createdDateMs, role, ownerEmail, memberCount }] }, name-sorted, covering your own boards and any shared with you. role is owner, editor or viewer. The id is what the card endpoints below take as projectID.
GET /mcp/searchProjects Authorization: Bearer csk_live_...
Create a new project to put cards on.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Max 30 characters |
Returns { id, name, createdDateMs }.
POST /mcp/addProject
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"name": "Website rebuild"
}
List the cards on your board. Returns the whole board at once rather than a page of it, since a board is meant to be read whole, so narrow it when you only want part.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | No | Search text matched against title/text; omitted means every card |
projectId | string | No | Restrict to one project's cards; omitted means every project you can see, shared boards included |
Returns { tasks: [{ id, title, projectID, status, type, text, highlights, links, createdDateMs }] }. status is one of open, in-progress, in-review. type is one of feature, bug, improvement, chore, docs, research, or null for a card with no type.
GET /mcp/searchProjectTasks?projectId=proj123 Authorization: Bearer csk_live_...
Fetch one card.
Scope: read
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
id | query string | string | Yes | The card id, as returned by /mcp/searchProjectTasks |
Returns { id, title, projectID, status, text, highlights, links, createdDateMs }. 404 if the card doesn't exist; 403 if you have no access to the board it sits on.
GET /mcp/getProjectTask?id=abc123 Authorization: Bearer csk_live_...
Add a card to one of your projects.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Max 40 characters |
projectID | string | Yes | Id of the project to add it to, from /mcp/searchProjects |
text | string | Yes | Max 100 lines, 600 characters per line; whole record capped at 10 KB |
status | string | No | One of open, in-progress, in-review; defaults to open |
type | string | No | One of feature, bug, improvement, chore, docs, research. Omitted leaves the card with no type |
Returns { id }, the new card's id. 404/403/400 if projectID isn't a live project you own or have editor access to.
POST /mcp/addProjectTask
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"title": "Rewrite the pricing page",
"projectID": "proj123",
"text": "Copy is stale and the annual price is wrong.",
"status": "open",
"type": "bug"
}
Revise a card on a board you can write to, which on a shared board includes cards other people added. Append-only, same as /mcp/updateTask: the old card is ceased and the returned id is a new one. Use /mcp/moveProjectTask instead when all you want is to change the column, since that keeps the card's id.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the card to revise |
title | string | Yes | Max 40 characters |
projectID | string | Yes | Project the card belongs to. A different one moves the card to another board |
text | string | Yes | Max 100 lines, 600 characters per line; whole record capped at 10 KB |
status | string | No | One of open, in-progress, in-review. Omitted keeps the card in its current column |
type | string or null | No | One of feature, bug, improvement, chore, docs, research. Omitted keeps the card's current type; null clears it |
Returns { id }, the new revision's id. 404 if the card doesn't exist; 403 if you only have view access to its board; 409 if it moved to another board mid-request.
POST /mcp/updateProjectTask
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123",
"title": "Rewrite the pricing page",
"projectID": "proj123",
"text": "Copy is stale and the annual price is wrong. Draft is in the shared doc."
}
Move a card to another column. A direct update rather than a revision, so the card keeps its id, exactly like dragging it on the board.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the card to move |
status | string | Yes | One of open, in-progress, in-review |
Returns { id, status }. 400 for an unknown status; 404 if the card doesn't exist; 403 if you only have view access to its board.
POST /mcp/moveProjectTask
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123",
"status": "in-progress"
}
Delete a card on a board you can write to, which is also how a finished card leaves the board. Not a hard delete, same soft-cease as /mcp/deleteCheat. On a shared board it disappears for everyone on it.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the card to delete |
Returns { success: true }. 404 if the card doesn't exist; 403 if you only have view access to its board.
POST /mcp/deleteProjectTask
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123"
}
Hours is your own private log of time worked and what it earned. A project carries an hourly rate and, optionally, the company the work is billed to; an entry is one block of time on one project, on one day. Like Pennies it is private-only, never appears in cheat search, and has no visible revision history. These are a separate list from the boards under Projects.
Every entry keeps the rate it was logged at, so a project's rate only ever prices future work, and correcting an entry's description never reprices it. You can add a project here, but changing its rate, archiving it and deleting it are done on the Hours page: a rate change reprices everything you log from then on, and deleting a project takes every hour on it with it.
List the projects you log time against, sorted by company then name.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
includeArchived | string | No | 1 to include archived projects. Their hours count toward every total either way |
Returns { projects: [{ id, name, company, hourlyRate, currency, archived, createdDateMs }] }.
GET /mcp/searchHourProjects Authorization: Bearer csk_live_...
Create a project to log time against.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Max 40 chars |
company | string | No | Who the work is billed to, max 40 chars |
hourlyRate | number | Yes | Default rate, 0 to 100000, in currency |
currency | string | Yes | ISO 4217 code |
Returns the new project, in the same shape as an entry in /mcp/searchHourProjects. The Free-tier project cap does not apply here, since write access already requires Pro.
POST /mcp/addHourProject
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"name": "Website rebuild",
"company": "Acme Ltd",
"hourlyRate": 60,
"currency": "GBP"
}
List your logged time, newest first, 30 to a page. Entries on archived projects are included.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
projectId | string | No | Restrict to one project |
company | string | No | Restrict to one company, matched exactly |
page | number | No | 1-indexed page number, default 1 |
Returns { entries: [{ id, projectID, projectName, company, date, minutes, description, hourlyRate, currency, amount, rateOverridden, createdDateMs }], totalPages, filters, projects }. date is UTC midnight of the day worked, in milliseconds; amount is what the entry earned. filters reports the filter actually applied and the companies and projects available to filter on.
GET /mcp/searchHourEntries?company=Acme%20Ltd Authorization: Bearer csk_live_...
Time worked and money earned today, this week, this month, this year and all time, plus a breakdown of up to 24 rows. Weeks run Monday to Sunday, and every boundary is UTC. If you have set a base currency on your user page, each tally is also converted into it at the rate on the day the work was done.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
granularity | string | No | What each breakdown row covers: day (default), week, month or year |
projectId | string | No | Restrict to one project |
company | string | No | Restrict to one company, matched exactly |
Returns { baseCurrency, granularity, filters, projects, tallies: { day, week, month, year, all }, breakdown, truncated }, where each tally is { minutes, hours, amounts: [{ currency, amount }], baseAmount, entryCount }. baseAmount is null rather than partial whenever any entry in that tally could not be converted.
GET /mcp/getHoursSummary?granularity=week Authorization: Bearer csk_live_...
Log a block of time on one project. Priced at the project's current rate unless you give one, and it keeps that rate if the project's rate changes later.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
projectID | string | Yes | Id of the project, from /mcp/searchHourProjects |
date | string or number | No | The day worked, as YYYY-MM-DD or milliseconds. Stored as UTC midnight of that day. Defaults to today (UTC) |
hours / minutes | number | Yes | Whole hours plus minutes. Together a multiple of 15 minutes, from 15 minutes to 24 hours |
description | string | Yes | What was done, max 200 chars |
hourlyRate | number | No | A rate for this entry alone, in the project's currency |
Returns the new entry, in the same shape as one in /mcp/searchHourEntries. The Free-tier entry cap does not apply here, since write access already requires Pro.
POST /mcp/addHourEntry
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"projectID": "abc123",
"date": "2026-10-02",
"hours": 2,
"minutes": 30,
"description": "Checkout page layout"
}
Correct an entry you own. Send only the fields that change: anything you leave out keeps what was logged. hours and minutes restate the whole duration together, so hours alone means exactly that many hours. The entry keeps the rate it was logged at unless you send hourlyRate or move it to another project, which prices it at that project's current rate. Saved as a new revision, so the entry comes back with a new id, though no revision history is exposed.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the entry to correct |
Any /mcp/addHourEntry field | No | The new value |
Returns the revised entry, with its new id. 404 if the entry doesn't exist; 403 if you don't own it; 409 if it has already been edited or deleted, in which case search again for the current version.
POST /mcp/updateHourEntry
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "def456",
"description": "Checkout page layout and review"
}
Delete an entry you own. Not a hard delete, same soft-cease as /mcp/deleteCheat, but the time stops counting toward every total and every timesheet export.
Scope: write
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Id of the entry to delete |
Returns { success: true }. 404 if the entry doesn't exist; 403 if you don't own it.
POST /mcp/deleteHourEntry
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "def456"
}
Dues is your own private record of money other people owe you. A payer is a client or a tenant, a service is one recurring thing you bill them for, and a due is one amount owed on one date. Like Pennies it is private-only, never appears in cheat search, and has no visible revision history.
Two things about it shape these endpoints. Each new due is priced at whatever the previous one for that service was priced at, not at the figure on the service, so editing a raised but unpaid due is what changes a payer's price from then on. And a service bills either on a fixed calendar date, whether or not the last one was paid, or only once the last one has been paid, which is what markDuePaid can quietly set in motion.
Three things the browser can do that these endpoints deliberately cannot. Freezing a payer stops billing a real client, and sending a reminder puts an email in front of them, so both stay on the page where you can see who you are doing it to. Editing a due reprices every future one, which is too much consequence for a call made in passing. All three are managed on the Dues page only.
Writing to Dues needs a Pro plan, the same as every other write endpoint here. Reading it does not.
The people and companies who owe you money, sorted by company then name. Use it to turn a name into an id, or to check somebody is not already on the list.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | No | Substring match over name and company, case-insensitive |
includeArchived | string | No | 1 to include archived payers, who are filed away but still owed money |
Returns { payers: [{ id, name, company, email, paymentMethod, paymentHandle, currency, notes, frozen, frozenAtMs, frozenReason, archived, createdDateMs }] }.
GET /mcp/searchDuePayers?q=acme Authorization: Bearer csk_live_...
The recurring things you bill your payers for, soonest next date first. Each carries how many charges it currently has unpaid, which is the thing a bare nextDueDate cannot tell you.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
payerId | string | No | Restrict to one payer |
q | string | No | Substring match over the service label, case-insensitive |
includeArchived | string | No | 1 to include archived services |
Returns { services: [{ id, payerID, label, amount, taxRate, currency, cycle, schedule, anchorDay, nextDueDate, outstandingCount, oldestUnpaidDueDate, frozen, archived, createdDateMs }] }. A service on the onPayment schedule will not bill again until its open charge is paid, whatever nextDueDate says.
GET /mcp/searchDueServices Authorization: Bearer csk_live_...
Add a person or company who owes you money. This is the one write on this API that stores another person's details, so it is worth being deliberate about what goes in it.
Scope: write
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Who owes the money, max 60 chars |
company | string | No | Their company, max 60 chars. Also groups the list |
email | string | No | Where a payment reminder would go, max 120 chars. A payer without one simply cannot be sent a reminder |
paymentMethod | string | No | How you want this payer to pay you, max 40 chars, e.g. PayPal or Monzo. Your own detail, not theirs, recorded per payer because different clients pay different ways |
paymentHandle | string | No | Where the money goes, max 120 chars: your PayPal address, your Monzo or Revolut handle, an account reference. Stored verbatim, so an @ or _ survives |
currency | string | No | ISO 4217 code their charges default to. Falls back to your account's base currency; a 400 names the field when neither is set |
notes | string | No | For your own reference, never emailed, max 200 chars |
Returns the payer, same shape as one entry of searchDuePayers.
POST /mcp/addDuePayer
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"name": "Bob Smith",
"company": "Acme Ltd",
"email": "[email protected]"
}
Set up a recurring thing a payer is billed for. This is what makes charges appear on their own later, so it is the most consequential call in this section.
A nextDueDate in the past raises every charge owed since then immediately, and the response says how many as raised. A monthly service back-dated a year creates twelve unpaid charges on the spot. That is how you record something you have already been billing for a while; a future date raises nothing.
Scope: write
| Parameter | Type | Required | Description |
|---|---|---|---|
payerName | string | Either | Who is billed, matched exactly and case-insensitively against a payer's name or company. An ambiguous name is a 400 rather than a guess |
payerId | string | Either | Who is billed, by id. Takes precedence over payerName |
label | string | Yes | What it is, max 60 chars. Becomes the description of the first charge |
amount | number | Yes | What it costs each time, before VAT. Only the seed price: every later charge copies the one before it |
taxRate | number | No | VAT percentage on top of amount, 0 to 100. Omitting it and sending 0 are different |
currency | string | No | ISO 4217 code. Defaults to the payer's own |
cycle | string | Yes | One of monthly, quarterly, yearly, none. none is a one-off |
schedule | string | No | fixed (default) bills on its date whether or not the last one was paid, so arrears stack up. onPayment raises the next one only once the current one is marked paid. Ignored for a none cycle |
nextDueDate | number | Yes | When the first charge falls due, as a millisecond timestamp, normalized to UTC midnight. Its day of the month becomes the anchor the cycle bills on |
Returns the service, same shape as one entry of searchDueServices, plus raised, the number of charges created on the spot.
POST /mcp/addDueService
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"payerName": "Acme Ltd",
"label": "Website hosting",
"amount": 120,
"taxRate": 20,
"cycle": "yearly",
"schedule": "onPayment",
"nextDueDate": 1789171200000
}
List what people currently owe you, most pressing first: unpaid before paid, oldest first within each. Not a text search, since the useful filters are who owes it and whether it is late.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
payerId | string | No | Restrict to one payer. The payer list comes back on every response, so one call gets you the ids |
status | string | No | One of unpaid, paid, or overdue. Overdue is the narrower slice of unpaid that is past its due date |
page | number | No | 1-indexed page number, default 1 |
Returns { dues: [{ id, payerID, payerName, payerCompany, payerEmail, payerFrozen, serviceID, serviceLabel, description, amount, taxRate, taxAmount, grossAmount, currency, dueDate, status, overdue, daysOverdue, paidDate, paidAmount, reminderSentAtMs, reminderCount, createdDateMs }], totalPages, filters, payers }. amount is before tax and grossAmount is what is owed. overdue and daysOverdue are worked out from the date at the moment you ask, so they are never stale.
GET /mcp/searchDues?status=overdue Authorization: Bearer csk_live_...
One due in full by id, including the payer's email and which service raised it.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The due's id |
Returns the same shape as one entry of searchDues. 404 if it does not exist, 403 if it is not yours.
GET /mcp/getDue?id=abc123 Authorization: Bearer csk_live_...
Your balances rather than the raw list. Money comes back per currency, and additionally converted into your base currency only when every due in that tally could be converted, so a baseAmount of null means mixed currencies rather than zero. Conversion uses the rate on each due's own date.
Scope: read
| Parameter | Type | Required | Description |
|---|---|---|---|
payerId | string | No | Restrict to one payer. Omit for everybody |
Returns { baseCurrency, filters, payers, tallies: { outstanding, overdue, upcoming, paidThisYear }, payerBreakdown, truncated }. Each tally is { amounts: [{ currency, amount }], baseAmount, dueCount }. upcoming is the next 30 days. Each payerBreakdown row adds oldestUnpaidDate, daysOverdue, overdueCount and frozen.
GET /mcp/getDuesSummary Authorization: Bearer csk_live_...
Record a one-off amount somebody owes you. Recurring charges are set up as services on the Dues page and raise themselves, so this is for one-offs and for catching up on something missed.
Scope: write
| Parameter | Type | Required | Description |
|---|---|---|---|
payerName | string | Either | Who owes it, matched exactly and case-insensitively against a payer's name or company. A name matching more than one payer is a 400 rather than a guess |
payerId | string | Either | Who owes it, by id. Takes precedence over payerName |
description | string | Yes | What it is for, max 200 chars |
amount | number | Yes | The amount owed before tax, 0 to 10000000 |
taxRate | number | No | VAT percentage on top of amount, 0 to 100. Omitting it and sending 0 are different: only one of them prints a VAT line |
currency | string | No | ISO 4217 code. Defaults to the payer's own currency |
dueDate | number | Yes | When it falls due, as a millisecond timestamp. Normalized to UTC midnight of that day |
Returns { id }.
POST /mcp/addDue
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"payerName": "Acme Ltd",
"description": "Website hosting 2027",
"amount": 120,
"taxRate": 20,
"dueDate": 1806019200000
}
Record that a due has been settled. If it came from a service that recurs only after payment, this also raises the next one straight away, dated one cycle on from this one's own due date and priced at whatever this one was priced at. That makes it more than a status change, so use it only when the money has actually arrived.
Scope: write
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The due's id |
paidDate | number | No | When it was paid, as a millisecond timestamp. Defaults to today |
paidAmount | number | No | What actually landed, if it differed from the gross owed. Defaults to the full gross |
Returns { success: true, id, rolledChildID }, where rolledChildID is the successor this raised, or null when nothing recurred. 400 if it is already marked paid.
POST /mcp/markDuePaid
Authorization: Bearer csk_live_...
Content-Type: application/json
{
"id": "abc123"
}
Everything above documents the REST API. To instead point Claude Code, Claude Desktop, or the Claude mobile app at Cheatsheet, including a sign-in flow that needs no key pasted anywhere, see Connect an MCP Client.