API v1

Connect Wabi.do
to your other tools —
over HTTP.

A REST API for your lists, tasks and comments — so Wabi.do plugs into the rest of your stack. One token, and anything you build sees exactly what your account sees, and nothing else.

3resources
120requests / minute
200per page, max
your first call
curl -H "Authorization: Bearer wabi_…" \
     https://app.wabi.do/api/v1/lists
const res = await fetch("https://app.wabi.do/api/v1/lists", {
  headers: { Authorization: "Bearer wabi_…" }
});
const { data } = await res.json();
import requests

r = requests.get(
    "https://app.wabi.do/api/v1/lists",
    headers={"Authorization": "Bearer wabi_…"},
)
data = r.json()["data"]
200 OK
{
  "data": [
    {
      "id": 3,
      "name": "Important and urgent",
      "task_count": 28,
      "members": [ … ]
    }
  ],
  "pagination": { "limit": 50, "has_more": false }
}

The whole surface

Read and write your workspace

Every call, and what a token needs to make it. A read-only token can reach the 5 marked read; the rest answer 403 insufficient_scope unless the token was created to write.

GET /lists read Every list you are a member of.
POST /lists read_write Create a list. You become its owner and first member.
GET /lists/{id} read One list.
PATCH /lists/{id} read_write Rename a list.
GET /lists/{id}/tasks read The tasks in a list.
POST /lists/{id}/tasks read_write Add a task to a list.
GET /tasks/{id} read One task.
PATCH /tasks/{id} read_write Change a task. Send only the fields you are changing.
GET /tasks/{id}/comments read The conversation on a task, oldest first.
POST /tasks/{id}/comments read_write Comment on a task.
PATCH /comments/{id} read_write Edit a comment you wrote.
Machine-readable & tooling One spec drives your client, your tests and your codegen. Import it, or explore it in a browser.

Getting a token #

  1. Open Wabi and click your avatar, at the bottom of the sidebar.
  2. Choose API Tokens, then Create new token.
  3. Pick a scope — Read only unless it needs to change things.
  4. 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" } }
StatusCodeMeans
401unauthorizedNo token, or a token that has been revoked.
403forbiddenYour token is valid, but this is not yours to change.
403insufficient_scopeA read-only token tried to change something. Issue a read_write token instead.
404not_foundNo such record — or one you have no access to. The two are the same answer on purpose.
422invalid_requestThe request was understood and the contents were not accepted. field names the culprit.
429rate_limitedToo many requests. Retry-After says how many seconds to wait.
500server_errorOur 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

FieldTypeNotes
idinteger
namestring
imagestring · nullCover image URL, or null.
task_countintegerTasks currently in the list.
membersMember[]Everyone with access.
created_at · updated_atstringISO 8601, UTC.

Task

FieldTypeNotes
idinteger
list_idintegerThe list it belongs to. Change it to move the task.
titlestring
statusstringThe task's state, e.g. open.
due_datestring · nullISO 8601, or null.
is_privateboolean
assigneeMember · null
tagsTag[]Each tag: id, name, color.
created_byintegerMember id of the creator.
created_at · updated_atstringISO 8601, UTC.

Comment

FieldTypeNotes
idinteger
task_idinteger
contentstring
authorMember
created_at · updated_atstringISO 8601, UTC.

Memberid (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.

GET/api/v1/listsread #

Every list you are a member of.

limitHow many to return. Default 50, maximum 200.
offsetHow 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.

statusopen or complete. Omit for both.
limitDefault 50, maximum 200.
offsetDefault 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", … } }

Comments #

The conversation on a task. Wabi's own entries — “status changed to complete” — are not comments and do not appear here.

GET/api/v1/tasks/{id}/commentsread #

The conversation on a task, oldest first.

limitDefault 50, maximum 200.
offsetDefault 0.
Response
{
  "data": [
    {
      "id": 5512,
      "task_id": 902,
      "content": "Sent it this morning.",
      "author": { "id": 5, "name": "Noa Barak", "email": "noa@example.com" },
      "created_at": "2026-08-05T09:02:00.000Z",
      "updated_at": "2026-08-05T09:02:00.000Z"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 3, "has_more": false }
}
POST/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.

Request
{ "content": "Sent it this morning." }
Response
{ "data": { "id": 5512, "task_id": 902, "content": "Sent it this morning.", … } }
PATCH/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.

Request
{ "content": "Sent it this morning, quote #4471." }
Response
{ "data": { "id": 5512, "content": "Sent it this morning, quote #4471.", … } }

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.createdA task was added to a list you are in.
task.updatedA task changed — title, due date, assignee, privacy, or reopened.
task.completedA task was marked complete.
comment.createdSomebody 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.