Tutorial · about 15 minutes
Your first integration
A script that files a task against a real project. Node.js, no dependencies beyond what ships with it.
What you need
- An API key with
projects.readandtasks.manage. - Node 18 or newer, for built-in
fetch.
export OCTOPUS_API_KEY="oct_live_..."Step 1: a client worth reusing
Because every response shares one envelope, one small wrapper removes all the repetition. Note that it raises on success: false rather than returning it: an integration that ignores a failure writes nothing and reports nothing, which is the worst of both.
const BASE = "https://gateway.octopusoperations.co.za/v1";
export async function octopus(path, { method = "GET", body } = {}) {
const response = await fetch(BASE + path, {
method,
headers: {
authorization: `Bearer ${process.env.OCTOPUS_API_KEY}`,
...(body ? { "content-type": "application/json" } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
const payload = await response.json();
if (!payload.success) {
// The API's own sentence is better than anything we would write here, and
// requiredScope tells you precisely which permission to go and ask for.
const detail = payload.requiredScope ? ` (needs ${payload.requiredScope})` : "";
throw new Error(`${method} ${path} → ${response.status}: ${payload.error}${detail}`);
}
return payload;
}Step 2: confirm the key
const me = await octopus("");
console.log("organisation:", me.organisation);
console.log("scopes:", me.scopes.map((s) => s.scope).join(", "));If this throws a 401, the key is wrong or revoked. If it prints scopes you did not expect, remember that asking for .manage also grants the matching .read.
Step 3: find a project
A task must belong to one, so pick the first the key can see:
const { data: projects, count } = await octopus("/projects");
if (!count) throw new Error("No projects visible to this key.");
const project = projects[0];
console.log("filing against:", project.name, project.id);Step 4: find someone to assign it to
Tasks are assigned to a person, by user id. If your key lacks personnel.read, take an id from an existing task instead: the code below tries the roster and falls back:
let assigneeId;
try {
const { data: people } = await octopus("/personnel");
assigneeId = people[0]?.userId ?? people[0]?.id;
} catch {
const { data: tasks } = await octopus("/tasks");
assigneeId = tasks.find((t) => t.assigneeId)?.assigneeId;
}
if (!assigneeId) throw new Error("Could not find anybody to assign to.");Step 5: file the task
const { data: task } = await octopus("/tasks", {
method: "POST",
body: {
title: "Inspect scaffolding on level 3",
projectId: project.id,
assigneeId,
dueDate: "2026-09-30",
priority: "high",
},
});
console.log("created:", task.id);Expect this to fail the first time
Leave out assigneeId and you get 400 assigneeId: Required. Leave out dueDate and you get the same about that. This is deliberate: a task nobody owns and nothing is due on cannot be chased, so Octopus refuses rather than creating one quietly.
Step 6: change it, and confirm
await octopus(`/tasks/${task.id}`, {
method: "PATCH",
body: { status: "in_progress" },
});
const { data: fresh } = await octopus(`/tasks/${task.id}`);
console.log("status is now:", fresh.status);If the read looks stale, it is not broken
A write returns as soon as the change is recorded; the row you read is written a moment later. It is normally a few milliseconds, but do not assert on a read taken in the same tick as the write. If you need certainty, subscribe to the change rather than re-reading.
Where to go next
- Build a live board, stop re-reading, start subscribing.
- Errors & retries, what to do when a call fails.
- Tasks reference, every field on a task.
