AsanableDocumentationSign in

Agents: the read-and-write loop

A program can watch for a card changing, work something out, and post the answer back as a comment. Three pieces make that loop: an outgoing webhook tells you a card changed, the REST API on this page reads the card, and the same API posts your result — attributed to a machine account, a named identity whose comments are visibly its own. If what you compute is an analysis of the ticket rather than a reply to somebody, post it as an AI review instead: same loop, its own versioned section on the card, and it interrupts nobody.

The loop, end to end

  1. An admin creates a machine account (“Invoice checker”), mints it a token, and points a webhook at your URL.
  2. A card changes. Asanable POSTs a signed card.* body to your endpoint — which carries the card’s reference and a link, and no description or comment text.
  3. You verify the signature, answer 200, then GET /api/v1/cards/AI-42 for the card itself and its thread.
  4. You compute whatever you compute.
  5. You POST /api/v1/cards/AI-42/comments. The comment appears on the card under the machine account’s name and avatar, and everybody following that card is notified — bell and Slack — exactly as if a colleague had written it. That last part is the point: an answer nobody is told about is not an answer.

Step 5 has a second door. A reviewer that re-reads a ticket every time it changes should not page the whole card each time it does, so it POSTs to /ai-review instead: the analysis lands in a versioned section of the card, attributed the same way, and notifies nobody. Reviews are the case that door was built for; replies still go to the thread.

A worked example of all five steps, in one file you can run, is further down.

Who the comment is from

Every write in Asanable is attributable to somebody who can be asked about it. The MCP connector satisfies that rule with the member who authorised it — its writes (a comment, a card, a subtask, a move) all act in their name. Here it is satisfied differently. A token does not have an identity of its own: it belongs to a machine account, a row an admin created and named, and the trail runs from the comment to that account to the person who minted the credential. The one token that does belong to a person — a member’s own read-only token, below — cannot write, so it never has to answer the question.

So a comment from a program is never anonymous and never in a colleague’s name. It says “Invoice checker”, in the colour the admin chose, and a reader can tell at a glance that a machine wrote it.

A machine account is deliberately not a user:

  • It cannot sign in. There is no password to steal and no session to obtain — its address is on a domain that can never exist, it has no Google identity, and the sign-in gate refuses it by kind as well.
  • It is never notified. No bell, no Slack. Commenting normally makes you a follower of a card, and it does become one — it simply never receives anything, so a handler cannot flood itself.
  • It is never an admin, and it does not appear in the workspace’s member list.
  • It can be assigned a card and @mentioned, because both are true statements about who is doing the work. It is not offered as a reviewer: a review request is a notification said in the second person, so asking an agent would be asking nobody.

Getting a token

An admin does this in Asanable, under the avatar menu → API tokens: create the machine account, then mint a token for it and tick the scopes. There is no endpoint that provisions either — which is what lets this page be public without being a way in.

Any member can mint a read-only token for themselves on the same page, no admin needed. It carries read and nothing else — the page offers no other box, and the server refuses one anyway — and it acts as you: it sees the boards you see, private ones included, and can change nothing. It is the right credential for a script or a command-line agent reading your own work, and the wrong one for anything that writes, which needs a machine account so its comments carry a machine’s name and not yours.

The token begins asa_live_, so one pasted into the wrong box is recognisable on sight and a search of your own repository finds it. It is shown exactly once: Asanable stores only a SHA-256 hash, so there is no query that could put it back on screen and no support request that can recover it. Lose it and mint another.

Treat it as a password. That is not a figure of speech — it is 256 random bits, and anyone holding it can read every board and comment as your machine account. Environment variable, secret manager, never a repository. Revoking is one click and takes effect on the next request.

ScopeWhat it opens
readEvery GET: the boards, a board’s cards, a card, its comment thread.
writePOST a comment, and nothing else.
write_fieldsPATCH a card’s fields, and nothing else.
write_ai_reviewPOST a version of a card’s AI review, and nothing else — not a comment.
write_cardsPOST a new card onto a board, and nothing else — not an edit to any existing card.

write_fields is deliberately not part of write. Every token minted before it existed carries write, so widening what that means would have handed field-editing power to credentials an admin granted for commenting, without anybody choosing it — a privilege that appears in an existing credential because of a deployment is a privilege nobody granted. So old tokens keep doing exactly what they did, and “this agent may comment but must not touch fields” is expressible, which is the shape most handlers actually want. If your agent needs to set a field, mint a new token with the box ticked.

write_ai_review is separate for the same reason, and the reason bites harder: the reviewing agent that needs it is usually the one that used to post its analysis as a comment. A token that carried both would still be able to do the thing the review section exists to stop. So mint the reviewer read and write_ai_review and leave write unticked: it can then read the card, read what it said last time, and post a review — and it cannot touch the conversation, set a field, or move anything.

The endpoints

Eleven of them, under /api/v1/, all authenticated with Authorization: Bearer asa_live_. A card is named by its referenceAI-42, the board key and the card’s number — which is what people say out loud and what a webhook body carries. Case does not matter, for a reference or for a board key.

EndpointScopeAnswers
GET /api/v1/boardsread200 and every board you may see, with its key and column names.
GET /api/v1/boards/{key}/cardsread200 and that board’s cards. ?column= narrows to one column; ?limit= caps the page.
POST /api/v1/boards/{key}/cardswrite_cards201 and the created card: its minted reference, column and URL. Only title is required; column and assignee are names — a column on the board, a member’s email.
GET /api/v1/cards/{reference}read200 and the card.
PATCH /api/v1/cards/{reference}write_fields200 and the card as it now is. Takes fields, custom_fields and done.
POST /api/v1/cards/{reference}/subtaskswrite_cards201 and the new subtask — its own reference, board-less at birth. A subtask is an ordinary card: every endpoint here takes its reference.
GET /api/v1/cards/{reference}/attachmentsread200 and a short-lived signed URL per file.
GET /api/v1/cards/{reference}/commentsread200 and the thread, oldest first.
POST /api/v1/cards/{reference}/commentswrite201 and the comment’s id.
GET /api/v1/cards/{reference}/ai-reviewread200 and every version of the AI review, oldest first.
POST /api/v1/cards/{reference}/ai-reviewwrite_ai_review201 and the version it became. Detailed below.
curl
# Learn what boards exist
curl -s https://asanable.accountable.eu/api/v1/boards \
  -H "Authorization: Bearer asa_live_…"

# Everything waiting in one column
curl -s "https://asanable.accountable.eu/api/v1/boards/AI/cards?column=Code%20review" \
  -H "Authorization: Bearer asa_live_…"

# Read a card
curl -s https://asanable.accountable.eu/api/v1/cards/AI-42 \
  -H "Authorization: Bearer asa_live_…"

# Post a comment as the token's machine account
curl -s -X POST https://asanable.accountable.eu/api/v1/cards/AI-42/comments \
  -H "Authorization: Bearer asa_live_…" \
  -H "Content-Type: application/json" \
  -d '{"body":"Checked 12 invoices. **Two are wrong**: AI-3 and AI-7."}'
GET /api/v1/cards/AI-42
{
  "id": "019f…",
  "reference": "AI-42",
  "title": "Invoices render blank for Belgian VAT",
  "description": "Only on the PDF, only for BE customers.",
  "board": { "key": "AI", "name": "AI" },
  "column": "In progress",
  "done": false,
  "assignee": { "id": "019f…", "name": "Bram De Vos" },
  "reviewers": [{ "id": "019f…", "name": "Anna Janssens" }],
  "due_date": "2026-08-14",
  "labels": ["urgent"],
  "sprints": ["10/08/26-21/08/26"],
  "fields": {
    "task_type": "Prod Bug",
    "ready_for_ai_review": "Yes",
    "ai_reviewed": null,
    "web_environment": "Web - Staging",
    "mobile_environment": null,
    "backend_environment": null
  },
  "attachments": [
    {
      "id": "019f…",
      "filename": "invoice.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 51234,
      "in_comment": false,
      "created_at": "2026-08-01T09:12:04.221Z"
    }
  ],
  "pull_requests": [
    {
      "repository": "Accountable-SA/asanable",
      "number": 42,
      "title": "Fix Belgian VAT rendering",
      "url": "https://github.com/Accountable-SA/asanable/pull/42",
      "author": "mokhtar",
      "state": "open",
      "review_state": "changes_requested",
      "opened_at": "2026-09-02T09:14:00.000Z",
      "merged_at": null,
      "closed_at": null
    }
  ],
  "custom_fields": [
    {
      "id": "019f8c21-4e77-7a3b-9d10-5f2c8e4b1a06",
      "name": "Squad",
      "kind": "multi_select",
      "scope": "workspace",
      "value": ["Revenue"]
    },
    {
      "id": "019f8c21-51b0-7c44-a2e8-9d7b3f16c400",
      "name": "Story points",
      "kind": "float",
      "scope": "board",
      "value": 2.5
    },
    {
      "id": "019f8c21-6d02-7e19-b7f3-4a1c8e05d922",
      "name": "Regression risk",
      "kind": "single_select",
      "scope": "board",
      "value": null
    }
  ],
  "created_at": "2026-07-30",
  "url": "https://asanable.accountable.eu/b/AI?card=019f…"
}

The card, key by key:

KeyValueDescription
idstring (uuid)The card’s stable identifier. Never changes; the same card keeps it across boards and renames.
referencestringAI-42 — the board key and the card’s number, the name people say and the one you pass back to these endpoints. Stable unless the card’s home board changes.
titlestringThe card’s title. Always present.
descriptionstring | nullThe body, as this app’s markdown; null when empty.
boardobject | nullThe card’s home board, as { key, name }. Null only for a card on no board (a bare subtask).
columnstring | nullThe name of the column the card sits in on its home board.
donebooleanWhether the card counts as completed — a done column, or a resolved “Won’t do”.
assigneeobject | nullWho it is assigned to, as { id, name }; null when unassigned. The id is what a @[Name](user:uuid) mention uses.
reviewersarray of objectsThe PR reviewers, each { id, name }; an empty array when there are none.
due_datestring | nullThe due date as YYYY-MM-DD (a calendar day, no time); null when unset.
labelsarray of stringsThe label names on the card; an empty array when there are none.
sprintsarray of stringsThe sprints the card is in, as dd/mm/yy-dd/mm/yy — the first date is the Monday the sprint starts, the second the Friday of the week after. A card can be in more than one; an empty array when it is in none.
fieldsobjectThe six base fields — task_type, ready_for_ai_review, ai_reviewed, web_environment, mobile_environment, backend_environment — as the chosen option’s name, or null. All six are always present. Keyed exactly as PATCH accepts them, so what you read is what you send back.
custom_fieldsarray of objectsEvery custom field the card’s board defines, each with this card’s value. Detailed below.
attachmentsarray of objectsWhat is attached, as metadata — id, filename, mime_type, size_bytes, in_comment, created_at. No URLs here; see attachments.
pull_requestsarray of objects§18 amendment 428: the GitHub pull requests that name this card, oldest first — repository, number, title, url, author, state (draft, open, merged, closed), review_state (null until GitHub says something) and the three dates. Read-only: the webhook is the only writer.
created_atstringThe day the card was created, as YYYY-MM-DD.
urlstringA deep link that opens the card in its pane on the board — safe to put in a comment or a Slack message.

Listing boards and cards

A webhook hands you a reference like AI-42 and nothing about the board it lives on. These two endpoints are how a handler finds its bearings: what boards exist, what columns they have, and what is sitting in one of them right now.

GET /api/v1/boards
{
  "boards": [
    {
      "key": "AI",
      "name": "AI",
      "description": "Model work",
      "columns": ["CS Dump", "Backlog", "In progress", "Code review", "QA", "Done"]
    }
  ]
}

key is what a reference is built from — AI-42 is board AI, card 42 — and columns is in board order, left to right. You only see boards you may see: a board somebody made private is absent, and asking for its cards answers 404 exactly as an unknown key does, so a key cannot be used to prove a private board is there.

GET /api/v1/boards/AI/cards?column=Code%20review&limit=50
{
  "board": { "key": "AI", "name": "AI" },
  "total": 212,
  "truncated": true,
  "cards": [
    {
      "id": "019f…",
      "reference": "AI-42",
      "title": "Invoices render blank for Belgian VAT",
      "column": "In progress",
      "done": false,
      "assignee": { "id": "019f…", "name": "Bram De Vos" },
      "due_date": "2026-08-14",
      "labels": ["urgent"],
      "url": "https://asanable.accountable.eu/b/AI?card=019f…"
    }
  ]
}
KeyValueDescription
boardobjectThe board, as { key, name } — echoed back so a response is self-describing in a log.
totalnumberHow many cards matched in total, before the page limit was applied.
truncatedbooleantrue when total is larger than the page you got. Said out loud rather than leaving you to compare lengths — a silently short list is the kind of thing a handler gets quietly wrong for a month.
cardsarray of objectsEach card’s reference, title, column, done, assignee, due_date, labels and url. Not the description, not the custom fields — fetch the card for those.

?column= matches a column name, case-insensitively, and answers 404 naming the real columns if that board has no such column. ?limit= takes 1–500 and defaults to 200.

There is no cursor, and that is deliberate rather than unfinished: paging implies a stable order, and board order is supposed to move — somebody drags a card and the sequence changes underneath you. A handler that wants everything should walk the columns; one that wants a particular card already knows its reference.

Custom fields, key by key

A custom field is defined by an admin in board settings; every card on that board can hold one value for it. One comes with the workspace — Squad — and the rest are whatever has been added since (Story points belongs to the Revenues & Expenses board alone). custom_fields lists every field the card’s board defines, not only the filled ones, so a field this card has left empty is present with a null value rather than missing. “This board has a Squad field and this card has no Squad” and “there is no Squad field” are different facts, and a caller iterating the array should not have to know the board’s configuration to tell them apart.

KeyValueDescription
idstring (uuid)The field’s stable identifier. Match on this, not on the name: a field can be renamed and is still the same field, and it is the handle the card.custom_field_changed webhook sends.
namestringWhat the admin called it, exactly as the card pane shows it. Good for logs and for prose; not an identifier.
kindstringOne of single_select, multi_select, date, integer, float, text. It tells you the shape of value, which is the next table.
scopestringworkspace for a field that exists on every board — Squad is one — or board for one belonging to this board alone. Two boards may each own a field called “Priority”; those are different ids, both board.
valuedepends on kindThis card’s value, with select choices resolved to their names rather than their ids. See below.

value by kind. The choices of a select arrive as names, because the option ids the database stores mean nothing to a caller that has not also fetched the field’s definition:

kindvalueEmpty
single_selectthe chosen option’s name, e.g. "High"null
multi_selectan array of names, e.g. ["Revenue", "OTC"][]
dateYYYY-MM-DD, a calendar day with no timenull
integera JSON number with no fractional part, e.g. 3null
floata JSON number, e.g. 2.5null
texta string, up to 500 charactersnull

This is the other half of a webhook. A card.custom_field_changed delivery tells you which field moved on which card and deliberately not what it now holds — a text field is prose somebody typed, and prose is never pushed to a URL. So the pattern is: take change.custom_field.id from the delivery, GET the card, and find that id in custom_fields. The webhooks page has the delivery side.

Custom fields are writable — by name, alongside the base fields, in the next section. Not their definitions: a token can set Squad on a card, and cannot add a choice to Squad, rename it, or create a field. Defining what a board’s fields are stays with the admin who owns the board.

Attachments, and getting the bytes

A card’s response lists what is attached but no URLs — signing costs a round trip to the storage host per file, and a handler reading a hundred cards to find one should not pay for a hundred sets of URLs it never fetches. Ask for them when you want them:

GET /api/v1/cards/AI-42/attachments
{
  "expires_in": 3600,
  "attachments": [
    {
      "id": "019f…",
      "filename": "invoice.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 51234,
      "in_comment": false,
      "created_at": "2026-08-01T09:12:04.221Z",
      "download_url": "https://…/storage/…?token=…"
    }
  ]
}
KeyValueDescription
expires_innumberSeconds the URLs below stay valid. Fetch now, do not store. A URL kept in a queue for an hour is a 403 waiting to happen — ask again instead.
download_urlstring | nullA signed, absolute URL you can GET with no credentials of your own — the signature is the credential. null for a file whose bytes have gone (see below); the rest of the list is still returned.
in_commentbooleantrue when the file arrived on a comment rather than on the card itself. A handler summarising a ticket usually wants the card’s own files and not every screenshot in the thread.

Signed URLs rather than bytes through this API, and that is a rule rather than a convenience: the app server never carries file bytes, so an agent reading a 40 MB PDF does not put 40 MB through the same process that is serving boards. You fetch it from the storage host directly, in parallel, exactly as the card pane does.

Files deleted in the app are absent. Asanable keeps a deleted row for thirty days so the deletion can be undone, but a file somebody deleted is not a file this API hands back. A file whose bytes were already purged is listed with download_url: null rather than failing the whole request — one missing file should not cost you the other nine.

Setting a field

PATCH /api/v1/cards/{reference}, scope write_fields. This is the write most handlers want: an agent that has read a card and formed an opinion marks AI Reviewed and moves on.

PATCH /api/v1/cards/AI-42
curl -s -X PATCH https://asanable.accountable.eu/api/v1/cards/AI-42 \
  -H "Authorization: Bearer asa_live_…" \
  -H 'Content-Type: application/json' \
  -d '{
    "fields": { "ai_reviewed": "Yes", "task_type": "Prod Bug" },
    "custom_fields": { "Squad": ["Revenue"], "Story points": 2.5 }
  }'

Two optional maps and an optional done flag, at least one present. done completes or reopens the card — a state, never a move; the card stays in its column and the board’s automations do any moving. fields takes the six base fields by their API name — task_type, ready_for_ai_review, ai_reviewed, web_environment, mobile_environment, backend_environment. custom_fields takes a board’s own fields, keyed by name or by id.

What you sendWhat happens
"ai_reviewed": "Yes"Set by the choice’s name, matched case-insensitively — so you can send back exactly what a GET gave you, without a second request to learn option ids.
"ai_reviewed": nullClears the field. This is a change and is recorded as one.
"Squad": ["Revenue"]A multi-select takes a list of names; a single-select takes one name. Names or ids both work.
"Story points": 2.5The scalar kinds take a JSON number, or a YYYY-MM-DD string for a date, or a string for text.
"ai_reviewed": "Maybe"422, naming the choices that would work. An unknown choice is never created: inventing one changes the board’s configuration, and a token that may set a field may not redefine what the field means.

Nothing is applied unless everything resolves. A body naming one good field and one bad one changes neither, so a 422 never leaves you guessing which half landed. On success you get 200 and the whole card back, in the same shape GET returns — so a handler can see what it did, including any value that came back in the board’s own spelling rather than the one it sent.

Every change is attributed. The card’s activity log gains “Invoice Checker set AI Reviewed to Yes” under the machine account’s name — that is what makes this write allowed at all, and the reasoning is in what it cannot do. Re-sending a value a card already holds records nothing and does not count against your hourly budget, so a handler that patches idempotently is not punished for it.

The comment thread

GET /api/v1/cards/AI-42/comments
{
  "comments": [
    {
      "id": "019f…",
      "author": { "id": "019f…", "name": "Bram De Vos", "kind": "person" },
      "body": "Only happens on BE invoices — the VAT block renders empty.",
      "deleted": false,
      "created_at": "2026-08-01T09:12:04.221Z",
      "edited_at": null
    },
    {
      "id": "019f…",
      "author": { "id": "019f…", "name": "Invoice checker", "kind": "agent" },
      "body": "Checked 12 invoices. **Two are wrong**: AI-3 and AI-7.",
      "deleted": false,
      "created_at": "2026-08-01T09:14:51.007Z",
      "edited_at": null
    }
  ]
}

author.kind is person or agent, which is how a handler tells its own past comments from a colleague’s and declines to answer itself. A deleted comment arrives with body: null and deleted: true — the text is not in the payload at all.

The POST body is { "body": "markdown" }, up to 20,000 characters. Markdown renders on the card the way a person’s comment does, and a mention written as @[Name](user:uuid) — the uuids are in the card response — really does mention that person, notification and all.

If what you are posting is an analysis rather than a reply, it belongs in the AI review instead. A comment notifies everybody following the card, which is right for an answer somebody is waiting on and wrong for a report that will be rewritten the next time the ticket changes.

The AI review

A card has one AI review, kept as versions. It has its own section on the card — the newest version open, the earlier ones behind a fold — and it is not part of the conversation.

It notifies nobody. That is the reason this endpoint exists rather than a comment: a reviewing agent woken by every change to a ticket used to page everybody following it each time it looked again. The review is simply there when the person opens the card. If something in your own systems needs to know, subscribe to card.ai_review_posted — with no bell and no Slack DM, that delivery is the only announcement there is.

POST /api/v1/cards/AI-42/ai-review
{
  "body": "## Ready to start\n\nRepro steps are clear and the VAT rule is named.\n\n**Missing:** which invoice numbers were affected."
}
201
{
  "id": "019fd7a2-11c4-7bd9-8e06-3a5f9c2d4b78",
  "version": 3,
  "author": { "id": "019f…", "name": "Invoice checker", "kind": "agent" }
}

The body is { "body": "markdown" }, up to 20,000 characters, and you do not send a version — Asanable allocates it from the versions that exist, so two posts about the same card are v1 and v2 however many copies of your handler are running. Nothing overwrites a review and nothing deletes one: there is no PATCH and no DELETE here, because keeping the earlier version is the feature. Markdown renders on the card — headings, lists and code all read as written.

GET /api/v1/cards/AI-42/ai-review
{
  "reviews": [
    {
      "id": "019f…",
      "version": 1,
      "body": "## Not ready\n\nNo reproduction steps.",
      "author": { "id": "019f…", "name": "Invoice checker", "kind": "agent" },
      "created_at": "2026-08-01T09:14:51.007Z"
    },
    {
      "id": "019f…",
      "version": 2,
      "body": "## Not ready\n\nSteps added, but no affected invoice numbers.",
      "author": { "id": "019f…", "name": "Invoice checker", "kind": "agent" },
      "created_at": "2026-08-03T11:02:18.440Z"
    }
  ]
}

The GET is worth building into your loop rather than skipping. Have I already reviewed this? is the question to ask before spending a model call, and the two answers without it are both bad: review the card again on every webhook, or keep your own record of what you told us — a second copy of our data that can disagree with ours.

Every version is attributed to the machine account that posted it, by name and avatar, in the card’s own section. A person cannot write one: there is no composer, and no scope a person’s session carries opens this door.

What it cannot do, deliberately

Three writes: a comment, a field, and an AI review. This API cannot create a card, move one between columns, change a title, description, assignee, due date or label, and cannot edit or delete anything — including its own comments and its own reviews.

The line is not arbitrary, and it moved once. Until amendment 225 a comment was the only write, on the grounds that a comment is the one whose author is visible: a card silently retitled by a program looks exactly like a card retitled by a colleague, and there was no cheap way for somebody reading a board to tell.

Fields joined it when that stopped being true. A field change now writes an activity entry naming whoever made it, so the card’s history reads “Invoice Checker set AI Reviewed to Yes”, with the machine account’s own name and colour — the same attribution its comments carry. The objection was answered rather than waived. Worth knowing that this fixed the same gap for people: nothing recorded who set these fields before, by any route, so the honest reading of the old rule was never “programs are untrustworthy” but “nobody could be asked”.

The AI review joined for the same reason and answers the same objection. It writes to a section of its own that names the machine account beside every version, so nothing it says can be mistaken for something a colleague wrote — which is precisely what a review posted as a comment could be mistaken for, three replies down a thread.

Everything still out of reach is out of reach for the same reason, unanswered: a title, a description and an assignee change what a card is or who owns it, and a column move changes what the board says about the work. If you need one, ask — each maps onto something the app already does, and each needs its own answer to “who did this?”

A private board does not exist to a token. Boards are public by default; one somebody has made private is absent from GET /api/v1/boards, and every card on it answers 404 — the same status, in the same words, as a reference that names nothing. That is deliberate: a 403 would let a caller use a key to prove a private board is there. A machine account creates no boards, so in practice it sees the public ones. A member’s personal token sees what that member sees, the private boards they created included — it is their account asking.

There is no search: no GET /api/v1/cards across boards, no query language. You can list the boards, list one board’s cards and fetch a card by name; anything cleverer is what the MCP connector is for, and that acts as the member who authorised it rather than as a machine account.

Errors and rate limits

Every failure is the same shape, so a client can branch on code rather than on English:

{ "error": { "code": "insufficient_scope", "message": "This token does not have the \"write\" scope. It has: read." } }
StatuscodeWhen
401unauthorizedNo token, a malformed header, an unknown token, a revoked one. Every one of those gets the same answer, on purpose.
403insufficient_scopeA real token, without the scope this endpoint needs. Ask an admin for a token that has it.
404not_foundNo card has that reference — or the path is not a reference at all.
422invalid_bodyThe JSON is malformed or the wrong shape; a comment is empty or too long; a field name or a choice is not one this board has; ?limit= is out of range. The message says which, and for a bad choice it lists the ones that would work.
429rate_limitedThis machine account has used its hour’s worth of comments, field changes or AI reviews. Wait.

60 comments an hour per machine account, and it exists for the people rather than for us: every comment notifies everybody following the card, so a handler stuck in a loop does not waste a database — it fills a colleague’s Slack. Two tokens for one account share the budget.

240 field changes an hour, counted separately because the harm is different in kind: a field change notifies nobody, so the number is set where a looping handler becomes visible — in a card’s activity log, and in any card.custom_field_changed webhook you have configured — rather than where it becomes rude. Re-sending a value a card already holds records nothing and is not counted, so patching idempotently is free. Reads are not limited at all.

60 AI reviews an hour, the comment ceiling’s number and not its reason. A review notifies nobody, so there is no attention to protect; what a looping handler would flood is the card’s own section, where every version is kept and shown. Unlike a field write there is no such thing as a free one — posting the same analysis twice makes two versions, and costs two.

A 429 or a 5xx is worth retrying; a 401, 403, 404 or 422 is not — nothing about them will be different in a minute.

A handler you can run

Everything above, in one file: it verifies the delivery, answers fast, fetches the card and its thread, works out whether the card is ready to start, and posts the verdict as a comment. Two environment variables — the webhook’s signing secret and the API token, each shown once in Asanable — and no dependencies beyond Express.

Node — the whole loop
import crypto from 'node:crypto'
import express from 'express'

// From the Webhooks settings page, shown once when the webhook is created.
const WEBHOOK_SECRET = process.env.ASANABLE_WEBHOOK_SECRET
// From the API tokens settings page, shown once when the token is minted.
const API_TOKEN = process.env.ASANABLE_API_TOKEN
const ASANABLE = 'https://asanable.accountable.eu'

const TOLERANCE_SECONDS = 300

const app = express()

/** Step 1 — verify the delivery really came from Asanable, and is not a replay. */
function verify(req) {
  const timestamp = req.get('X-Asanable-Timestamp') ?? ''
  const offered = (req.get('X-Asanable-Signature') ?? '').replace(/^sha256=/, '')
  const body = req.body.toString('utf8')

  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${timestamp}.${body}`, 'utf8')
    .digest('hex')

  const a = Buffer.from(offered, 'hex')
  const b = Buffer.from(expected, 'hex')
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null

  // A verified signature over an hour-old timestamp is a verified replay.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return null

  return JSON.parse(body)
}

/** Step 2 and 4 — the REST API. One helper, because every call is the same shape. */
async function asanable(path, init = {}) {
  const response = await fetch(`${ASANABLE}/api/v1${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${API_TOKEN}`,
      'Content-Type': 'application/json',
      ...init.headers,
    },
  })

  if (!response.ok) {
    // { "error": { "code": "…", "message": "…" } } — always, on every failure.
    const { error } = await response.json().catch(() => ({ error: null }))
    throw new Error(`${response.status} ${error?.code ?? 'unknown'}: ${error?.message ?? ''}`)
  }

  return response.json()
}

// The raw body, not the parsed one: re-serialising changes key order and the
// signature stops matching for reasons that look like a configuration problem.
app.post('/hooks/asanable', express.raw({ type: 'application/json' }), async (req, res) => {
  const event = verify(req)
  if (!event) return res.status(401).send('bad signature')

  // Answer immediately. You have 10 seconds, and the work below can take longer.
  res.status(200).send('ok')

  // Deduplicate on the delivery id: it is stable across retries, and a delivery
  // recorded as failed may well have arrived.
  const deliveryId = req.get('X-Asanable-Delivery')
  if (alreadyHandled(deliveryId)) return

  // Route on the header rather than parsing to decide — `X-Asanable-Event`.
  if (event.event !== 'card.created' && event.event !== 'card.updated') return

  try {
    await handle(event.card.reference)
  } catch (err) {
    console.error('[asanable] could not handle', event.card.reference, err)
  }
})

async function handle(reference) {
  // Step 2 — read the card. Scope: read. `card.fields` holds the base fields by name,
  // keyed exactly as the PATCH below sends them back.
  const card = await asanable(`/cards/${reference}`)

  // Already dealt with? `fields` is readable, so a handler can be idempotent without
  // keeping its own state anywhere.
  if (card.fields.ai_reviewed !== null) return

  // The bytes, if there are any. Metadata is on the card; URLs are signed on request,
  // are short-lived, and are fetched from the storage host rather than through the API.
  if (card.attachments.length > 0) {
    const { attachments } = await asanable(`/cards/${reference}/attachments`)
    for (const file of attachments.filter((f) => f.download_url && !f.in_comment)) {
      const bytes = await fetch(file.download_url).then((r) => r.arrayBuffer())
      console.log(`read ${file.filename}, ${bytes.byteLength} bytes`)
    }
  }

  // Step 3 — compute something. Trivially, here: is this card ready to start?
  const thread = await asanable(`/cards/${reference}/comments`)
  const missing = []
  if (!card.description || card.description.trim().length < 40) missing.push('a description')
  if (!card.assignee) missing.push('an assignee')
  if (!card.due_date) missing.push('a due date')

  // Say nothing rather than repeat yourself: skip if our last word stands.
  const ours = thread.comments.filter((c) => c.author.kind === 'agent')
  if (ours.length > 0 && missing.length === 0) return

  const body =
    missing.length === 0
      ? `**Ready to start.** Description, assignee and due date are all set.`
      : `**Not ready yet** — still missing ${missing.join(', ')}.`

  // Step 4 — post the answer back. Scope: write. It appears on the card under the
  // machine account's own name, and everybody following the card is notified.
  await asanable(`/cards/${reference}/comments`, {
    method: 'POST',
    body: JSON.stringify({ body }),
  })

  // Step 5 — record the verdict as a field, so a board can be filtered on it.
  // Scope: write_fields. Choices go by name, exactly as the card returned them, and
  // the activity log will read "<machine account> set AI Reviewed to Yes".
  //
  // Sending the value the card already holds is a no-op: nothing is recorded and it
  // does not count against the hourly budget, so this is safe to run on every event.
  await asanable(`/cards/${reference}`, {
    method: 'PATCH',
    body: JSON.stringify({
      fields: { ai_reviewed: missing.length === 0 ? 'Yes' : 'No' },
    }),
  })
}

const handled = new Set()
function alreadyHandled(deliveryId) {
  if (!deliveryId || handled.has(deliveryId)) return true
  handled.add(deliveryId)
  return false
}

app.listen(3000)

Two details in there are the ones people skip. The raw body: the HMAC is computed over the bytes as received, because JSON.parse then JSON.stringify changes key order and the signature stops matching for reasons that look like a configuration problem. The delivery id: retries repeat the same body with the same X-Asanable-Delivery, so deduplicate on it or your agent will answer the same card five times. The in-memory Set above is a placeholder — use whatever your process already has that survives a restart.

The author.kind === 'agent' check is the other one worth copying. An agent that comments on a card it is following will receive a card.commented webhook for its own comment; reading the thread and recognising itself is how it declines to reply to itself forever.

While you are building this, a tunnel (ngrok, Cloudflare Tunnel) gives you the https URL a webhook requires and reaches your laptop. The REST calls work from anywhere with the token.

Reference: the webhook headers you need

The full webhook story — every event, the payload, the retry schedule, Python — is on its own page. These four are the ones the handler above touches:

HeaderValue
X-Asanable-Signaturesha256=<hex> — HMAC-SHA256 over {timestamp}.{body}, keyed by the webhook’s secret.
X-Asanable-TimestampUnix seconds, covered by the signature. Reject anything older than your tolerance — a verified signature over an old timestamp is a verified replay.
X-Asanable-DeliveryThe delivery id. Stable across retries — deduplicate on this.
X-Asanable-EventThe event name, so you can route without parsing the body.

Need the boards read rather than one card answered? The MCP connector is that, for a person’s Claude.