Integration
Recipes
Whole flows, from the token to concluding an epic.
Build a board from scratch
Workspace, project, label, epic and the first card, in the order the dependencies require.
export API=http://localhost:3000/api
export TOKEN="the token from My account → Security"
auth=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json")
# 1. the workspace, born with the 5 default columns
TENANT=$(curl -s "${auth[@]}" -X POST $API/tenants \
-d '{ "name": "Support", "color": "sky" }' | jq -r .id)
# 2. the entry column, from the workspace set
COLUMN=$(curl -s "${auth[@]}" "$API/columns?tenant=$TENANT" | jq -r '.[0].id')
# 3. project and label
PROJECT=$(curl -s "${auth[@]}" -X POST $API/projects \
-d "{ \"tenantId\": \"$TENANT\", \"name\": \"Integrations\" }" | jq -r .id)
TAG=$(curl -s "${auth[@]}" -X POST $API/tags \
-d "{ \"tenantId\": \"$TENANT\", \"name\": \"api\" }" | jq -r .id)
# 4. the epic, which inherits the project's workspace
EPIC=$(curl -s "${auth[@]}" -X POST $API/epics \
-d "{ \"projectId\": \"$PROJECT\", \"name\": \"Onboarding\" }" | jq -r .id)
# 5. the card
curl -s "${auth[@]}" -X POST $API/cards \
-d "{
\"tenantId\": \"$TENANT\",
\"title\": \"Publish the documentation\",
\"columnId\": \"$COLUMN\",
\"projectId\": \"$PROJECT\",
\"epicId\": \"$EPIC\",
\"tagIds\": [\"$TAG\"]
}"const API = "http://localhost:3000/api";
const token = process.env.NOTEBUGS_TOKEN!;
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(API + path, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...init.headers,
},
});
// The HTTP code is the contract; `error` is the sentence for readers.
if (!response.ok) {
const { error } = await response.json();
throw new Error(`${response.status}: ${error}`);
}
return response.json();
}
const tenant = await api<{ id: string }>("/tenants", {
method: "POST",
body: JSON.stringify({ name: "Support", color: "sky" }),
});
const columns = await api<{ id: string }[]>(`/columns?tenant=${tenant.id}`);
const project = await api<{ id: string }>("/projects", {
method: "POST",
body: JSON.stringify({ tenantId: tenant.id, name: "Integrations" }),
});
const card = await api("/cards", {
method: "POST",
body: JSON.stringify({
tenantId: tenant.id,
title: "Publish the documentation",
columnId: columns[0].id,
projectId: project.id,
}),
});Move a card and read the history
# afterCardId null drops it at the top of the destination column
curl -s "${auth[@]}" -X POST $API/cards/$CARD/move \
-d "{ \"columnId\": \"$OTHER_COLUMN\", \"afterCardId\": null }"
# the column change became a history row
curl -s "${auth[@]}" $API/cards/$CARD/historyEdit in bulk without losing labels
# in a batch, labels are ADD/REMOVE and not the final list,
# so the labels each card already had are not erased
curl -s "${auth[@]}" -X PATCH $API/cards/bulk \
-d "{
\"cardIds\": [\"$C1\", \"$C2\", \"$C3\"],
\"branch\": \"release/2026-09\",
\"addTagIds\": [\"$TAG\"]
}"Set a due date that means the same day for everyone
// The two fields travel together: the instant, and the zone it was
// DECLARED in. Without `dueAtZone`, an 11pm due date in Sao Paulo would
// show up the next day for whoever reads it in UTC.
await api(`/cards/${cardId}`, {
method: "PATCH",
body: JSON.stringify({
dueAt: "2026-10-15T23:59:00.000Z",
dueAtZone: "America/Sao_Paulo",
}),
});
// In a PATCH, an absent `dueAtZone` PRESERVES the stored zone.
// To clear the due date, send an explicit null:
await api(`/cards/${cardId}`, {
method: "PATCH",
body: JSON.stringify({ dueAt: null }),
});Conclude an epic that still has pending cards
# without deciding the pending cards, this is 422
curl -s "${auth[@]}" -X PATCH $API/epics/$EPIC \
-d '{ "status": "DONE" }'
# conclude the pending cards where they are…
curl -s "${auth[@]}" -X PATCH $API/epics/$EPIC \
-d '{ "status": "DONE", "pendingCards": "DONE" }'
# …or send what is left into the next epic
curl -s "${auth[@]}" -X PATCH $API/epics/$EPIC \
-d "{
\"status\": \"DONE\",
\"pendingCards\": \"MOVE\",
\"pendingEpicId\": \"$NEXT\"
}"Attach a file
# multipart: with no hand-written Content-Type, curl writes the boundary
curl -s -H "Authorization: Bearer $TOKEN" \
-F "[email protected]" \
$API/cards/$CARD/attachments
# the response carries the url ready for Markdown
# [screenshot](/api/files/18ab694d-….png)const form = new FormData();
form.append("files", file);
const response = await fetch(`${API}/cards/${cardId}/attachments`, {
method: "POST",
// No Content-Type: FormData writes the boundary itself.
headers: { Authorization: `Bearer ${token}` },
body: form,
});
const [attachment] = await response.json();
console.log(attachment.url); // /api/files/<server-generated name>.pngMake a backup and wait for it
curl -s "${auth[@]}" -X POST $API/backup/job \
-d '{ "destination": "LOCAL" }'
# exporting is a JOB: follow it until it leaves PENDING/RUNNING
until [ "$(curl -s "${auth[@]}" $API/backup/job | jq -r .status)" = "READY" ]; do
sleep 2
done
curl -s "${auth[@]}" -o notebugs-backup.zip $API/backup/file