Getting a token #
- Open Wabi and click your avatar, at the bottom of the sidebar.
- Choose API Tokens, then Create new token.
- Pick a scope — Read only unless it needs to change things.
- Copy it. It is shown once and cannot be read again afterwards.
A token acts as you.
Within its scope it can reach everything your account can, so treat it like
a password: keep it on a server, never in a browser or a mobile app, and
revoke it from the same screen the moment it is no longer in use.
Authentication #
One header, on every request.
Authorization: Bearer wabi_1a2b3c…
Without it, or with a token that has been revoked, you get 401
and nothing else. Tokens do not expire on their own; they last until you
delete them.
Scopes
| read |
May GET. Anything that would change something answers
403 insufficient_scope. This is the default — a token
you did not think about cannot damage anything. |
| read_write |
May also create and edit. Ask for it when you need it. |
A scope cannot be changed after the fact. Widening one silently would mean
a credential you already handed out quietly gaining powers, so instead you
create a new token and delete the old one.
Rate limit
120 requests per minute per account. Over it you get 429 with a
Retry-After header in seconds. It is set well above what an
integration needs and is really there to stop a polling loop that forgot to
sleep.
How the token is stored
It is not. We keep a SHA-256 of it and the first few characters, which is why
the value is shown once when you create it and never again — and why nobody
here can read it back to you. If you lose it, delete the token and create
another.
Conventions #
| Envelope |
Every success answers { "data": … }. Lists also carry
pagination. |
| Lists |
What the product calls a list is a list here. If you
see workspace anywhere, that is the database's word and
not part of this contract. |
| Names |
snake_case throughout. |
| Times |
ISO 8601 in UTC, always. Send any offset you like; you get UTC back. |
| PATCH |
Changes only what you send. null clears a field that can
be cleared. |
| Paging |
limit and offset, ceiling 200, so one call
cannot ask for an entire account. |
What we promise.
Fields get added; they do not get renamed or removed under /api/v1.
Build against the fields you use and ignore the rest, and an upgrade on our
side stays invisible on yours.
Errors #
Every failure is the same object, so you can handle it in one place.
{ "error": { "code": "invalid_request", "message": "A task needs a title", "field": "title" } }
| Status | Code | Means |
401 | unauthorized | No token, or a token that has been revoked. |
403 | forbidden | Your token is valid, but this is not yours to change. |
403 | insufficient_scope | A read-only token tried to change something. Issue a read_write token instead. |
404 | not_found | No such record — or one you have no access to. The two are the same answer on purpose. |
422 | invalid_request | The request was understood and the contents were not accepted. field names the culprit. |
429 | rate_limited | Too many requests. Retry-After says how many seconds to wait. |
500 | server_error | Our fault. Retrying is reasonable. |
Versioning & stability #
Every route lives under /api/v1 — the version is in the path.
Build defensively: read the fields you need and ignore the ones you don't, so a new field in a response never breaks your integration.
Idempotency & retries
GET reads nothing and PATCH lands the same result every time, so both are safe to repeat. POST creates a new resource on each call — retry it only after a network error where no response came back, never after a 4xx.
Rate limits
The budget is 120 requests per minute per account. Past it, calls return 429 with a Retry-After header telling you how many seconds to wait — back off for that long rather than retrying immediately.
Objects #
The three resources this API returns. Ids are integers; timestamps are ISO 8601 in UTC.
List
| Field | Type | Notes |
id | integer | |
name | string | |
image | string · null | Cover image URL, or null. |
task_count | integer | Tasks currently in the list. |
members | Member[] | Everyone with access. |
created_at · updated_at | string | ISO 8601, UTC. |
Task
| Field | Type | Notes |
id | integer | |
list_id | integer | The list it belongs to. Change it to move the task. |
title | string | |
status | string | The task's state, e.g. open. |
due_date | string · null | ISO 8601, or null. |
is_private | boolean | |
assignee | Member · null | |
tags | Tag[] | Each tag: id, name, color. |
created_by | integer | Member id of the creator. |
created_at · updated_at | string | ISO 8601, UTC. |
Comment
| Field | Type | Notes |
id | integer | |
task_id | integer | |
content | string | |
author | Member | |
created_at · updated_at | string | ISO 8601, UTC. |
Member — id (integer), name (string), email (string). Appears inside members, assignee and author.
Lists #
What the product calls a list is a workspace in the database. This layer says list, because that is the word on the screen.
Every list you are a member of.
limit | How many to return. Default 50, maximum 200. |
offset | How many to skip. Default 0. |
Response
{
"data": [
{
"id": 3,
"name": "Important and urgent",
"image": null,
"created_at": "2026-03-11T08:12:00.000Z",
"updated_at": "2026-08-05T17:40:00.000Z",
"task_count": 28,
"members": [
{ "id": 5, "name": "Noa Barak", "email": "noa@example.com" }
]
}
],
"pagination": { "limit": 50, "offset": 0, "total": 12, "has_more": false }
}
POST/api/v1/listsread_write
#
Create a list. You become its owner and first member.
Request
{ "name": "Q3 launch" }
Response
{
"data": {
"id": 41,
"name": "Q3 launch",
"image": null,
"created_at": "2026-08-05T18:02:11.000Z",
"updated_at": "2026-08-05T18:02:11.000Z",
"task_count": 0,
"members": [ … ]
}
}
GET/api/v1/lists/{id}read
#
One list.
Response
{ "data": { "id": 3, "name": "Important and urgent", "task_count": 28, "members": [ … ] } }
PATCH/api/v1/lists/{id}read_write
#
Rename a list.
Request
{ "name": "Q3 launch — shipped" }
Response
{ "data": { "id": 41, "name": "Q3 launch — shipped", … } }
Tasks #
A task belongs to exactly one list. Moving it between lists is a field on the task, not a separate call.
GET/api/v1/lists/{id}/tasksread
#
The tasks in a list.
Private tasks belong to whoever wrote them: a token sees its own, never a colleague's, even in a list you share.
status | open or complete. Omit for both. |
limit | Default 50, maximum 200. |
offset | Default 0. |
Response
{
"data": [
{
"id": 902,
"list_id": 3,
"title": "Send the quote to Dana",
"status": "open",
"due_date": "2026-08-09T21:00:00.000Z",
"is_private": false,
"assignee": { "id": 5, "name": "Noa Barak", "email": "noa@example.com" },
"tags": [ { "id": 2, "name": "urgent", "color": "#ee1c4e" } ],
"created_by": 5,
"created_at": "2026-08-02T06:30:00.000Z",
"updated_at": "2026-08-05T11:20:00.000Z"
}
],
"pagination": { "limit": 50, "offset": 0, "total": 28, "has_more": false }
}
POST/api/v1/lists/{id}/tasksread_write
#
Add a task to a list.
title is the only required field. An assignee has to already be a member of the list.
Request
{
"title": "Send the quote to Dana",
"due_date": "2026-08-10T00:00:00+03:00",
"assignee_id": 5,
"is_private": false
}
Response
{ "data": { "id": 902, "list_id": 3, "title": "Send the quote to Dana", … } }
GET/api/v1/tasks/{id}read
#
One task.
Response
{ "data": { "id": 902, "list_id": 3, "status": "open", … } }
PATCH/api/v1/tasks/{id}read_write
#
Change a task. Send only the fields you are changing.
Send due_date: null or assignee_id: null to clear either one. list_id moves the task to another list you belong to.
Request
{ "status": "complete" }
Response
{ "data": { "id": 902, "status": "complete", "updated_at": "2026-08-05T18:14:02.000Z", … } }
Webhooks #
Have us POST to you when something changes, instead of asking. Set one up
under your avatar → Webhooks: a URL, the events you want,
and a signing secret you can copy.
Events
task.created | A task was added to a list you are in. |
task.updated | A task changed — title, due date, assignee, privacy, or reopened. |
task.completed | A task was marked complete. |
comment.created | Somebody commented on a task. |
An endpoint only ever receives events about lists you belong to, and a
private task stays with whoever wrote it — the same boundary the API
enforces.
What arrives
A POST with a JSON body and three headers.
X-Wabi-Event: task.created
X-Wabi-Delivery: evt_9f2c4a1b7e3d5064
X-Wabi-Signature: sha256=8c1f…
{
"id": "evt_9f2c4a1b7e3d5064",
"type": "task.created",
"created_at": "2026-08-07T09:14:02.000Z",
"data": {
"task": {
"id": 902,
"list_id": 3,
"title": "Send the quote to Dana",
"status": "open"
},
"internal_event": "task_created"
}
}
internal_event is a hint, not part of the contract — it
names the specific change behind a broad type, so you can tell a rename
from a due-date change without us minting an event per field. Build
against type.
Check the signature before you trust the body
Every request is signed with your endpoint's secret, over the exact bytes
we sent. Without this check, anyone who learns your URL can post whatever
they like to it — and a webhook URL leaks easily, through logs, screen
shares and debugging tools.
const crypto = require("crypto");
function verify(rawBody, header, secret) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// rawBody must be the raw request body, NOT a re-serialised object —
// JSON.stringify(JSON.parse(body)) can reorder keys and change the signature.
app.post("/hooks/wabi", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Wabi-Signature"), process.env.WABI_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
res.sendStatus(200); // answer first, do the work after
});
The same event can arrive twice.
Delivery is at-least-once: we retry until you answer 2xx, and
a reply that got lost on the way back looks exactly like one that never
came. Keep the id of what you have processed and ignore a
repeat. There is also no ordering guarantee — two events sent moments
apart can arrive in either order, so use the record in the payload rather
than assuming the sequence.
Answering
Any 2xx means received. Anything else — or a timeout after
10 seconds — is a failure and we try again, backing off: roughly 10
seconds, then a minute, 5 minutes, 25 minutes, 2 hours. After 6 attempts
that delivery is abandoned.
Answer quickly and do the work afterwards. If you finish your processing
before replying, a slow job turns into a timeout and then into duplicate
deliveries of something you already handled.
An endpoint that fails 10 times in a row is switched off, and you will see
it paused on the settings screen. Fix the receiver and press resume.
Two requirements
| https |
Required. The body carries task titles and comment text; over
http anyone on the path reads it, and the signature
does not help — it proves the body was not tampered with, not
that nobody else saw it. |
| Public URL |
We have to be able to reach it. For local development use a
tunnel rather than localhost. |
Comments #
The conversation on a task. Wabi's own entries — “status changed to complete” — are not comments and do not appear here.
/api/v1/tasks/{id}/commentsread #The conversation on a task, oldest first.
limitoffset/api/v1/tasks/{id}/commentsread_write #Comment on a task.
@mentions are parsed and notified exactly as they are when someone types them in the app.
/api/v1/comments/{id}read_write #Edit a comment you wrote.
Yours only. Being in a list lets you read its conversation; it does not let you rewrite what someone else said.