Outgoing webhooks
Asanable POSTs a signed JSON body to a URL when a card changes — created, moved, assigned, completed, commented on. Modelled on Asana’s webhooks, minus the handshake: there is no subscription API and no X-Hook-Secret dance, because endpoints are configured by an admin inside the app rather than registered by a program.
Getting an endpoint set up
An admin adds it in Asanable, under the avatar menu → Webhooks: the URL, which events it wants, and whether it watches one board or the whole workspace. Asanable generates the signing secret at that moment and shows it exactly once — it begins whsec_, so a secret pasted into the wrong box is recognisable on sight.
So there is nothing on this page you can call to create a webhook, and no API key to request. If you are building the receiver and somebody else runs the workspace, what you need from them is the secret and the list of events; what they need from you is an https URL.
The events
| Event | When |
|---|---|
card.created | A card is created. On the board, from the quick-add composer, from an import, from Intercom or from a Slack shortcut — every path that makes a card. |
card.moved | A card moves to another column. A card changes column. Reordering within a column is not an event — a card’s neighbours are not its state. |
card.assigned | A card’s assignee changes. The assignee changes in either direction. A to_assignee of null is an unassignment. |
card.review_requested | Someone is asked to review a card. Someone is added as a reviewer. Removing one is not an event — the request is what was made. |
card.completed | A card is completed. The card became done: the Mark Complete toggle, or a move into a column that means done. |
card.reopened | A completed card is reopened. A done card became not-done again. |
card.archived | A card is archived. The card was archived. Archiving something already archived fires nothing. |
card.commented | Someone comments on a card. Someone posted a comment. The text is not in the body — see below. |
card.updated | A card’s title, description, due date or index changes. Title, description, due date or index changed. change.fields says which. |
card.custom_field_changed | A card’s custom field value changes (Squad, Story points, …). One of the board’s custom fields got a different value on this card — Squad, Story points, or anything an admin has added in board settings. change.custom_field says which field; the new value is not in the body — see below. Setting a value while creating a card is not this event: card.created already reported it. |
card.field_changed | Any other field changes — Task Type, Ready for AI Review, AI Reviewed, an environment, the sprints or the labels. Any other field changed: Task Type, Ready for AI Review, AI Reviewed, either environment, the sprints, or the labels. change.field says which, and this one does carry the value — from and to for a single-select, values for the sprints or labels the card has now. Detailed below. |
card.ai_review_posted | An agent posts an AI review on a card. An agent posted a version of the card’s AI review through the REST API. change.ai_review says which review and which version — the review’s text is not in the body, for the reason card.commented withholds a comment’s. The one event nobody is notified about in the app, which is why a receiver that cares has to subscribe here. |
A card entering a done column fires two events, card.moved and card.completed, so a receiver routing on “completed” does not have to know which of the workspace’s columns are done ones. Every event fires only when the old value and the new one actually differ — a save that changed nothing sends nothing.
Not events, deliberately. Labels, sprints, custom field values, subtask and dependency links, attachments, comment edits and deletions, and board or column administration. A webhook that fired on everything would be a firehose you would have to filter anyway. Each of those maps onto something the database already records, so adding one later is one value and one call site — ask.
The payload
{
"event": "card.moved",
"delivery_id": "019fd63d-0603-798a-a9a6-1e4bcab59637",
"occurred_at": "2026-08-06T14:22:31.884Z",
"actor": { "id": "019f…", "name": "Anna Janssens" },
"card": {
"id": "019f…",
"reference": "AI-42",
"title": "Invoices render blank for Belgian VAT",
"url": "https://asanable.accountable.eu/b/AI?card=AI-42",
"board": { "id": "019f…", "key": "AI", "name": "AI" },
"column": "In progress",
"done": false,
"due_date": "2026-08-14",
"assignee": { "id": "019f…", "name": "Bram De Vos" }
},
"change": { "from_column": "Todo" }
}actor is null when nobody did it — a board automation, or an import. change is present only when the event has something to add:
| Event | change |
|---|---|
card.moved | from_column, a name. The column it entered is card.column. |
card.assigned | from_assignee and to_assignee, each { id, name } or null. |
card.review_requested | reviewer: { id, name }. |
card.commented | comment_id, so a redelivery is recognisable. Never the text. |
card.updated | fields: some of title, description, due_date, index. |
card.custom_field_changed | custom_field: { id, name, kind, scope } — the field, never its value. Detailed below. |
card.field_changed | field: { key, label }, plus from and to for a single-select or values for the sprints and labels. Detailed below. |
card.ai_review_posted | ai_review: { id, version } — which review and which version of it. Never the review’s text. Detailed below. |
An AI review was posted
A reviewing agent posts its analysis of a card through POST /api/v1/cards/{reference}/ai-review, and this event says so. Posting again after the card changes makes a new version rather than replacing the last one, so version counts up and never repeats.
// The third review of this card, from a reviewing agent.
"change": {
"ai_review": {
"id": "019fd7a2-11c4-7bd9-8e06-3a5f9c2d4b78",
"version": 3
}
}This is the only event nobody is told about in the app. A comment rings a bell and sends a Slack DM; an AI review deliberately does neither — it waits in its own section on the card, which is the whole point of it not being a comment. So if something downstream needs to know a fresh review exists, this delivery is the only notice it will get.
version is here, where card.commented carries an id alone, because it is the one fact worth acting on without a second request: v3, and I last saw v2 is answerable from the body. The review’s text is not, for the reason set out below — a review quotes the ticket it reviewed, and a bug report’s reproduction steps are as telling about a customer as any comment. Read it back with GET /api/v1/cards/{reference}/ai-review under a token an admin minted and can revoke.
Every other field
card.field_changed covers the fields no other event does: the six base single-selects — task_type, ready_for_ai_review, ai_reviewed, web_environment, mobile_environment, backend_environment — plus the card’s sprints and its labels.
// A single-select: Ready for AI Review, ticked.
"change": {
"field": { "key": "ready_for_ai_review", "label": "Ready for AI Review" },
"from": null,
"to": "Yes"
}// A multi-valued field: the sprints this card is in now.
"change": {
"field": { "key": "sprints", "label": "Sprint" },
"values": ["10/08/26-21/08/26", "24/08/26-04/09/26"]
}| Key | Value | What it is |
|---|---|---|
field.key | string | The name the REST API uses, so a delivery turns straight into a PATCH. One of the five above, or sprints or labels. |
field.label | string | What a person reads — “AI Reviewed”. For logs and messages. |
from | string | null | For a single-select: the option name it held before, or null if it was empty. |
to | string | null | The option name it holds now. null means the field was cleared — the key is always present, so a missing one never has to be read as “unchanged”. |
values | array of strings | For sprints and labels: the whole set the card carries now, not the one that moved. An empty array means the last one was removed. |
This event carries the value, where a custom field does not. That is a deliberate difference rather than an inconsistency: a custom field can be of kind text — free prose somebody typed — so rather than judging six kinds one at a time, none of them travels. These fields are a closed set of choices an admin configured (“Yes”, “Bug”), so there is no prose to leak, and a receiver that had to make a second authenticated call to learn whether AI Reviewed became Yes or No would be holding a puzzle rather than an event.
Why the set, for sprints and labels. “Which sprints is this card in now?” is the question a receiver actually asks, and answering it from a stream of additions and removals — which may arrive out of order after a retry — is strictly harder. Diff against what you last saw if you want the delta.
What does not fire. A field set while a card is being created — card.created has already reported it. A write that does not change the value, so re-saving what a card already holds is silent. Anything done by an import or an automation, which have no actor to name. And the planning estimate (days left), deliberately: it is a working guess a planner nudges repeatedly during a session, and a delivery per keystroke is not an event anybody wants.
Custom fields
A custom field is defined by an admin in board settings, and every card on that board can hold one value for it. One ships with the workspace — Squad — and the rest are whatever has been added since. When one changes on a card you get a card.custom_field_changed delivery whose change.custom_field names it:
"change": {
"custom_field": {
"id": "019f8c21-4e77-7a3b-9d10-5f2c8e4b1a06",
"name": "Squad",
"kind": "multi_select",
"scope": "workspace"
}
}| Field | Type | What it is |
|---|---|---|
id | string | UUID, stable for the life of the field. Match on this, not on the name — a field can be renamed and stays the same field. |
name | string | What the admin called it, exactly as the card pane shows it. Convenient for logs; not an identifier. |
kind | string | One of single_select, multi_select, date, integer, float, text. It tells you what shape the value has when you fetch it. |
scope | string | board for a field belonging to this board alone, or workspace for one that exists on every board — Squad is a workspace field. Two boards can each have their own field called “Priority”; those are different ids and both are board. |
The value is not in the delivery, for the same reason a description body never is. A field of kind text is free text somebody typed, and a date or a number against a named card can be just as revealing — so rather than judging six kinds one at a time, none of them travels. Read the value back with the REST API when you need it: GET /api/v1/cards/AI-42 returns every custom field with this card’s value, under a credential you control — the same bargain card.commented makes about comment text. Take change.custom_field.id from the delivery and find it in the response’s custom_fields; the schema is here.
Two things worth knowing before you route on this event. It fires per field, so a card whose Squad and Story points both change produces two deliveries. And it only fires on a real change: the pane saves a number field when it loses focus, so tabbing through Story points without editing it sends nothing, and re-picking the same set of Squad choices sends nothing either. Clearing a field, however, is a change and does fire.
What is not in it, and why
No description body. No comment body. No AI review body. Ever, on any event.
The reason in one sentence: descriptions and comments are where staff write about customers, incidents and each other, and a URL an admin typed into a settings page has been vetted no more carefully than the Slack workspace that has never been sent them either — less carefully, in fact, since nobody signed an agreement with it.
So a card.commented delivery tells you that Bram commented on AI-42 and gives you the link; whoever needs the words follows the link and signs in on the way. card.updated names description as a field that changed and does not say what it now says. The type that builds the body has nowhere to put prose, so no future event can leak one by forgetting the rule.
An AI review is machine prose rather than a person’s, so this rule did not obviously reach it — and it does anyway, because a review quotes the ticket it is reviewing. A bug report’s reproduction steps say as much about a customer as any comment does, and a summary of them says it more compactly.
A webhook does get a little more than a Slack message does: ids, the column name, the done flag and the due date. None of that is free text a person wrote about someone.
Verifying a delivery
| Header | Value |
|---|---|
X-Asanable-Signature | sha256=<hex> — HMAC-SHA256 over {timestamp}.{body}, keyed by your secret. |
X-Asanable-Timestamp | Unix seconds. Covered by the signature, so it cannot be edited. |
X-Asanable-Delivery | The delivery id. Stable across retries — deduplicate on this. |
X-Asanable-Event | The event name, so you can route without parsing the body. |
Two things your endpoint must do, and the second is the one people skip:
- Recompute the HMAC over the raw request body. Not over a re-serialised object —
JSON.parsethenJSON.stringifychanges key order and whitespace, and the signature stops matching for reasons that look like a configuration problem. Compare in constant time. - Check that the timestamp is recent. A signature alone proves the body came from us; it does not prove when. Signing
{timestamp}.{body}is what makes a replay detectable — but only if you look, because a verified signature over an hour-old timestamp is a verified replay. Five minutes is the usual tolerance.
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.ASANABLE_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 300
const app = express()
// The raw body, not the parsed one. This is the whole trick.
app.post('/hooks/asanable', express.raw({ type: 'application/json' }), (req, res) => {
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', 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 res.status(401).send('bad signature')
}
// Verified — now check it is not a replay.
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
return res.status(401).send('stale timestamp')
}
const event = JSON.parse(body)
console.log(event.event, event.card.reference, event.card.url)
// Answer fast; do the work afterwards. You get 10 seconds.
res.status(200).send('ok')
})import hashlib, hmac, json, os, time
from flask import Flask, request
SECRET = os.environ["ASANABLE_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
app = Flask(__name__)
@app.post("/hooks/asanable")
def asanable():
timestamp = request.headers.get("X-Asanable-Timestamp", "")
offered = request.headers.get("X-Asanable-Signature", "").removeprefix("sha256=")
body = request.get_data() # bytes, exactly as received
expected = hmac.new(SECRET, f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(offered, expected):
return "bad signature", 401
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return "stale timestamp", 401
except ValueError:
return "bad timestamp", 401
event = json.loads(body)
print(event["event"], event["card"]["reference"], event["card"]["url"])
return "ok", 200Retries and timing
An endpoint answering 2xx within 10 seconds is a success. Anything else — a 4xx, a 5xx, a timeout, a refused connection, a TLS failure — is a failure, and every attempt is recorded with the status line and the first 500 characters of your response body, where the admin can read it.
| After failure | Next attempt in |
|---|---|
| 1st | ~1 minute |
| 2nd | ~5 minutes |
| 3rd | ~20 minutes |
| 4th | ~2 hours |
| 5th | never — the delivery is marked dead |
Roughly two and a half hours of trying in total. The first attempt happens immediately, after the response to whoever changed the card has been sent; retries are swept every ten minutes, so a one-minute gap means “on the next sweep”.
A retry sends the same bytes, with the same delivery_id; only the timestamp and therefore the signature differ, because the signature covers the timestamp on purpose. Deduplicate on X-Asanable-Delivery — a delivery recorded as failed may well have arrived, if your handler was slow or answered 500 after doing the work.
Order is not guaranteed. A retried card.moved can land after the card.completed that followed it. occurred_at is the truth, and the card as it is now is behind the link.
Redirects are not followed. A 3xx is recorded as a failure telling the admin to configure the final URL. Following one would hand the destination back to whoever controls the endpoint, which is how a public host turns into a cloud metadata address.
What a URL is allowed to be
Asanable validates the URL when it is saved, because a settings field that makes this server POST anywhere is a request-forgery primitive. https only; no credentials in the URL; no private, loopback or link-local address in v4 or v6; no .local, .internal or single-label host.
While you are building the receiver, a tunnel (ngrok, Cloudflare Tunnel) gives you an https URL that passes those checks and reaches your laptop. https://localhost:3000 will not be accepted, and would not work if it were — deliveries come from servers in Frankfurt.
The admin’s settings page has a Send a test button that mints a real signed webhook.test delivery over an obviously fictional card (TEST-1 on a board called Sample board) carrying a note that says so, POSTs it to your real URL and waits for the answer. Handle that event name, or ignore it — do not act on it as though a card changed.
Answering back
A webhook tells you a card changed and gives you a link. If the thing you want to build also has something to say — a check that ran, a total that came out wrong, a summary — the return leg is a small REST API and a machine account: read the card with GET /api/v1/cards/AI-42, and post your result with POST /api/v1/cards/AI-42/comments. The comment appears on the card under the account’s own name, and everybody following the card is notified.
Agents & the REST API documents the whole loop, with a Node handler that verifies a delivery, fetches the card and comments on it in one file.
Reading rather than reacting? The MCP connector is the other direction — asking Asanable questions.
