Nobe

Developers

Connect your own systems to Nobe: read and add tasks, post to the team, and get told when something changes. Available on the Pro plan.

Start here

Nobe is open: your own systems can read and add tasks, and post a message to the team channel. You and your IT people build the connection; we give you a small, stable API and this guide.

The API is available on the Pro plan. Everything is JSON over HTTPS under https://doable-olive.vercel.app/api/v1. Version 1 is a promise: we only add fields and endpoints, we never rename, change or remove one. Ignore fields you do not know.

In the examples, secrets.apiKey means wherever your system keeps its secrets. Keep the key there, never in the code.

  1. Sign in as an owner or admin and open Settings, then Integrations.
  2. Make a key: give it a name and tick what it may do. Copy it at once: it is shown only once.
  3. Check it works with the first example below.
Check your key
curl https://doable-olive.vercel.app/api/v1/me \
  -H "Authorization: Bearer $NOBE_API_KEY"

Keys and what they may do

Send the key in the Authorization header: Bearer, a space, the key. Never put it in a web address, in a page a browser loads, or in code you share. The API has no browser access on purpose: call it from your server.

Each key belongs to one company and has scopes, chosen when the key is made. A key cannot be widened later: make a new one. A company can have up to 20 active keys, and a key has no expiry in version 1: revoke it in Settings when you no longer need it and it stops working at once.

  • tasks:read: list and read tasks, task types and teams.
  • tasks:write: create tasks, change them, mark them done.
  • people:read: the names and team ids of active people (nothing else about a person).
  • messages:write: post a text message to the team channel.
  • Not available in the API: Second Brain, direct messages, billing, settings, managing people, comments, photos.

Create, read and finish tasks

A task needs a title. Everything else is optional: description, priority (low, normal, high, urgent), due_at, team_id, assignee_person_id, task_type_id and external_id. Find the ids with GET /teams, /task-types and /people (each returns the whole list as { data: [...] }).

GET /tasks returns { "data": [...], "next_cursor": ... }. Use ?limit= (up to 100) and pass next_cursor as ?cursor= for the next page. A new task answers 201 with the task.

Create a task (curl)
curl -X POST https://doable-olive.vercel.app/api/v1/tasks \
  -H "Authorization: Bearer $NOBE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-blocked" \
  -d '{ "title": "Pallet 4 is blocking dock door 2", "priority": "high", "external_id": "ORDER-1042" }'
Create a task (Node.js)
const response = await fetch("https://doable-olive.vercel.app/api/v1/tasks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${secrets.apiKey}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "order-1042-blocked",
  },
  body: JSON.stringify({ title: "Pallet 4 is blocking dock door 2", priority: "high", external_id: "ORDER-1042" }),
});
const task = await response.json();
if (!response.ok) throw new Error(task.error.message);
console.log(task.id, task.status);
Create a task (Python)
import requests

API_KEY = "..."  # read it from your secret store

response = requests.post(
    "https://doable-olive.vercel.app/api/v1/tasks",
    headers={"Authorization": f"Bearer {API_KEY}", "Idempotency-Key": "order-1042-blocked"},
    json={"title": "Pallet 4 is blocking dock door 2", "priority": "high", "external_id": "ORDER-1042"},
    timeout=10,
)
response.raise_for_status()
print(response.json()["id"])
Find it again by your own number
curl "https://doable-olive.vercel.app/api/v1/tasks?external_id=ORDER-1042" \
  -H "Authorization: Bearer $NOBE_API_KEY"
Mark it done
curl -X POST https://doable-olive.vercel.app/api/v1/tasks/TASK_ID/complete \
  -H "Authorization: Bearer $NOBE_API_KEY"

Retry safely: external_id and Idempotency-Key

Networks fail, and your system will retry. Two things stop a retry from making a second task.

external_id is your own reference, for example an order number: 1 to 100 characters from A-Z a-z 0-9 . _ : / -. It is unique among the company's tasks that are not cancelled; a second task with the same one answers 409 with existing_task_id. It can be set when the task is made and not changed afterwards.

Idempotency-Key is a header on POST /tasks and POST /messages (1 to 64 characters). Send the same key and the same body again within 24 hours and you get the first response back, with Idempotent-Replayed: true, and nothing is created. The same key with a different body answers 422 idempotency_key_reused.

Post a message to the team

Plain text of 1 to 4000 characters, to the team channel only. It shows in the chat as sent by the integration (the key's name), never as a person. It sends no notification and no task is made from it.

Post a message
curl -X POST https://doable-olive.vercel.app/api/v1/messages \
  -H "Authorization: Bearer $NOBE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "text": "Inbound truck 7 has arrived at dock 3." }'

Errors, limits and retries

Every key may make 60 requests a minute. Over that you get 429 with a Retry-After header (seconds); wait that long. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a unix time), and X-Request-Id: quote it if you ask us for help.

Every error is JSON: { "error": { "code": "...", "message": "...", "fields": { ... } }, "request_id": "..." }. Retry only 429 and 5xx, with growing waits (1, 2, 4, 8 seconds). Do not retry 4xx: fix the request.

  • 401 unauthorized: the key is missing, wrong, revoked or expired (the same answer for all, on purpose).
  • 403 forbidden_scope: the key lacks the scope. 403 plan_required: the company is not on the Pro plan.
  • 404 not_found: no such thing in your company.
  • 409 conflict: external_id already used, or the same Idempotency-Key is still being processed.
  • 413 body over 256 KB. 415 not application/json. 422 validation_failed: look at error.fields.
  • 429 rate_limited. 500 server_error: our fault; retry later.

Webhooks: we call you

Instead of asking again and again, let us tell you. In Settings > Integrations add an https address and pick the events: task.created, task.updated, task.completed, message.posted. We send a POST with a JSON body: { id, type, created_at, data }. For task events data is the task; for message.posted it is the team-channel message.

Every call is signed so you know it came from us. The X-Nobe-Signature header is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of the text t + "." + the raw body, made with your webhook's secret (shown once when you add it). Check it on the raw body before you parse it, compare in constant time, and refuse a call whose t is more than 5 minutes old.

Answer with any 2xx within 10 seconds. Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours. X-Nobe-Delivery is the same on every retry: use it to ignore a repeat. Order is not guaranteed: use created_at and the task's updated_at. After 15 failed attempts in a row the webhook is paused and the owners and admins are told in the app; resume it in Settings with one click (a test call is offered). The last 50 deliveries of each webhook are listed there: status, response code, attempt, time and what went wrong, never the answer your server sent.

We only call public https addresses (port 443 or 8443) written as a name, not a bare IP address. Addresses inside your network, localhost and link-local addresses are refused, and redirects are not followed.

For message.posted, data holds message_id, channel (always team), sender_label, text and created_at. Only messages in the team channel are sent: never direct messages, and never private tasks.

Check a signature (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received (a string or Buffer), not parsed JSON.
export function isFromUs(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map(part => part.split("=")));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.t || !parts.v1 || age > 300) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1, "hex");
  const wanted = Buffer.from(expected, "hex");
  return given.length === wanted.length && timingSafeEqual(given, wanted);
}
Check a signature (Python)
import hashlib, hmac, time

def is_from_us(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    if abs(time.time() - int(parts.get("t", 0))) > 300:
        return False
    expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
A task.completed call
{
  "id": "evt_7f3c",
  "type": "task.completed",
  "created_at": "2026-10-08T12:30:00Z",
  "data": { "id": "6b1f0c3e-0c1e-4e7e-9a53-1f2b6f0a7d11", "external_id": "ORDER-1042", "title": "Pallet 4 is blocking dock door 2", "status": "resolved", "completed_at": "2026-10-08T12:29:41Z" }
}

Connect your warehouse system

A warehouse management system (WMS) already knows when something is wrong: a pallet is blocked, a truck is late, a pick failed. This guide turns those moments into tasks your team sees at once, and tells your WMS when the task is done. Your IT person needs about a day.

  1. Make a key in Settings > Integrations named after the system, for example Warehouse system. Tick tasks:read, tasks:write and people:read. Store it in your WMS's secret store, never in its code.
  2. Find the ids you need once: call GET /teams (for example Receiving, Picking, Dispatch) and GET /task-types, and keep them in your WMS settings.
  3. When your WMS raises an alert, create a task: POST /tasks with a clear title, the right team_id, a priority and external_id set to your own number (the order, pallet or dock id). Send an Idempotency-Key built from the same event so a retry never makes a second task.
  4. A 409 on external_id means the task already exists: treat that as success and use existing_task_id.
  5. To follow the work, add a webhook for task.completed (and task.updated if you want changes). When it arrives, find your record by external_id, mark the alert closed and release the dock or pallet. Check the signature first.
  6. Test with a made-up order number, and use Send a test in Settings to see a delivery appear in the list.
  7. Before you go live: handle 429 and 5xx with waiting and retrying, log the X-Request-Id of failures, and keep a second key ready so you can rotate the first without a gap.
WMS alert to task (Node.js)
// Called by your WMS when a pallet is blocked.
async function raiseBlockedPallet(alert) {
  const response = await fetch("https://doable-olive.vercel.app/api/v1/tasks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${secrets.apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `alert-${alert.id}`,
    },
    body: JSON.stringify({
      title: `Pallet ${alert.pallet} is blocking dock door ${alert.door}`,
      priority: "high",
      team_id: secrets.dispatchTeamId,
      external_id: `ALERT-${alert.id}`,
    }),
  });
  if (response.status === 409) return; // already there
  if (response.status === 429 || response.status >= 500) throw new Error("try again later");
  if (!response.ok) console.error((await response.json()).request_id);
}
Task done to WMS (Node.js, Express)
import express from "express";

const app = express();
// Keep the raw body: the signature is over the exact bytes.
app.post("/doable-hook", express.raw({ type: "application/json" }), (req, res) => {
  if (!isFromUs(req.body.toString("utf8"), req.get("X-Nobe-Signature"), secrets.webhookSecret)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  if (event.type === "task.completed" && event.data.external_id?.startsWith("ALERT-")) {
    releaseDock(event.data.external_id); // your WMS call
  }
  res.sendStatus(200);
});

More

The machine-readable description of every endpoint is the OpenAPI file: /developers/openapi.json. Import it into your API tool to try the calls.

Need something the API does not do yet? Tell us at info@getnobe.com. We add things, never take them away.