Skip to main content

Projects & Work Items

The Projects API covers a project, its sprints, and the work inside them. It is what you call from a CI pipeline that files a bug on a failed build, from a form that raises a change request, or from a script that keeps another tracker in step with Laabam One.

  • Base path: https://api.laabam.one/v1/projects/{company} (also reachable at https://laabam.app/api/v1/projects/{company}).
  • {company} is your company slug — copy it from Settings → API Keys → Integration details.
  • Scopes: projects.read and projects.write. Write does not imply read being enough — a read-only key cannot move a card.

A user story, a task and a bug are one thing

This is the one idea worth reading before you write any code against it.

In Laabam One they are one record with a type, on one board — not three resources. So there is a single create endpoint, and type decides what you are raising:

epic · feature · user_story · task · bug · issue · test_case

The fields that only make sense for one type are simply optional. A user story uses as_a, i_want, so_that and acceptance_criteria; a bug uses repro_steps and severity. Nothing stops you sending them on another type, and nothing requires them.

Ask /meta first

GET /v1/projects/{company}/meta

It returns the work-item types, the priorities, the five fixed state categories — and, most importantly, your company's own configured statuses.

Status names are yours to rename. A team that renames "In Review" to "With QA" is doing something completely ordinary, and a client that hardcoded in_review breaks that afternoon. /meta is how you avoid ever hardcoding them.

State, and the one thing the API will refuse

Every work item carries two things:

stateFree text — your status name. Carried verbatim.
state_categoryOne of five fixed values: proposed, in_progress, resolved, completed, removed.

The category is what every chart reads. It is the difference between "this work is finished" and "this work is not", so it is never guessed.

That means: if you send a state this company has not configured, the API returns 422 rather than filing it under a category of its choosing. Send one of

  • a status slug from /meta — translated for you, or
  • a state together with its state_category.

Refusing is deliberate. The alternative — defaulting an unrecognised status to "new" — would reopen everything already resolved, deferred or removed the first time a client sent a status with a typo in it.

Where your own vocabulary lands

The ones that come up when a team migrates off another tracker:

You call itstate_categoryWhy
Abandoned, cancelled, won't fix, duplicateremovedWork that will not be done. Only completed counts toward velocity and burns down, so filing an abandoned story as completed reports it as delivered.
Partially done, in QA, with the clientin_progressStarted and not finished. resolved is the narrower "developer done, not yet accepted" — nothing part-finished belongs there.
On hold, deferred, backlogproposedNot started. This is where the seeded deferred status sits, not in removed — it is work still expected.

Note that review — ready-for-test — is in_progress, not resolved. Developer-done is not done.

state keeps your word for it either way — send {"state": "abandoned", "state_category": "removed"} and the board reads "Abandoned". The category is only what the charts count it as.

Creating work

POST /v1/projects/{company}/projects
Content-Type: application/json

{ "name": "Website Rebuild" }

code is optional, and it matters more than it looks: the code becomes the prefix of every work-item reference in the projectWEB-1, WEB-2. Send your own if the team already says "WEB-14" out loud; leave it out and one is derived from the name.

Then raise something:

POST /v1/projects/{company}/projects/12/work-items
Content-Type: application/json

{
"type": "bug",
"title": "Checkout returns 500 on an empty cart",
"repro_steps": "1. Empty the cart\n2. Press Checkout",
"severity": "high",
"priority": "urgent"
}

The response carries the generated reference. Do not send one — references are issued per project under a lock, which is what stops two concurrent callers being handed the same number.

Assigning it to someone

assigned_to takes an employee id, so resolve the person first:

GET /v1/projects/{company}/assignees
{
"success": true,
"data": [
{ "id": 41, "name": "Priya Raman", "employee_code": "EMP-0041", "designation": "Senior Developer" }
]
}

Four fields, on purpose. employees is an HR table — salary, bank details, identity documents — and this is a picker, not an employee API. There is nothing else behind it.

An id that is not on that list is refused with 422. It is not quietly dropped, which is the failure worth knowing about: work that arrives unassigned looks exactly like work nobody has got to yet.

Moving something: use /transition

POST /v1/projects/{company}/projects/12/work-items/480/transition
Content-Type: application/json

{ "status": "done", "comment": "Deployed to staging, verified by QA." }

Status changes have their own endpoint rather than being a field on the update, for two reasons:

  1. A state change is an event. It writes the history row that your burndown, cumulative-flow diagram and cycle time are all derived from.
  2. You should not be able to move a card by accident while renaming its title.

Entering the completed category stamps the finish time; leaving it clears that again, so "finished on" cannot outlive a reopen.

The optional comment lands on the item's timeline. Without it the board records what changed and never why.

Organising the work: iterations, areas and milestones

Beside the sprint, a board has three more dimensions. Each has list, create and update endpoints under the project.

Iterations/iterationsThe time boxes you plan in. A tree — a release containing sprints containing weeks.
Areas/areasThe parts of the product work belongs to. Also a tree — Checkout, Checkout/Payments.
Milestones/milestonesDated commitments the project is measured against. Flat.

A work item carries iteration_id and area_id, so these are how you resolve the ids to send.

The trees

Nest by sending parent_id. The pathRelease 1/Sprint 2 — is derived from the ancestry and read-only; you cannot send it. A path you supplied could disagree with the parent_id you supplied beside it, leaving a node that displays in one place and belongs in another, with nothing to reconcile the two.

POST /v1/projects/{company}/projects/12/areas
Content-Type: application/json

{ "name": "Payments", "parent_id": 4 }
{ "success": true,
"data": { "id": 9, "parent_id": 4, "name": "Payments", "path": "Checkout/Payments" } }

Reparenting refuses a loop: a node cannot become its own ancestor. That is reachable with two ordinary calls — put B under A, then A under B — and it hangs everything that walks the tree.

An iteration_id, area_id or parent_id belonging to another project is refused with 422. It used to be accepted silently for the first two.

Milestones: status and date move together

PATCH /v1/projects/{company}/projects/12/milestones/3
{ "status": "completed" }

Completing one stamps completed_date if you didn't send it; moving it back out of completed clears the date. Otherwise "delivered on" reads null for finished work, or carries a finish date for work that isn't finished.

status is one of pending, in_progress, completed, overdue.

A PATCH that changes nothing is refused

Every field on every update endpoint is optional, so you can send one. The consequence used to be that a body naming none of them returned 200 {"success":true,"message":"…updated."} with the record echoed back unchanged.

That now returns 422, naming the fields it accepts. If you get it unexpectedly, check the header — curl -X PATCH -d '{...}' without Content-Type: application/json sends form-encoded, nothing parses as JSON, and the request arrives naming no fields at all.

Reading

GET /v1/projects/{company}/projects/12/work-items?type=bug&open_only=true

Use open_only=true for "not finished". It excludes both the completed and the removed categories — the distinction most easily got wrong by listing categories by hand.

Fetching one item includes its status_history: every state change, with how long it sat in the previous state.

Lists are cursor-paged?limit= (1–100, default 25) and ?cursor=:

{
"success": true,
"data": [ ... ],
"paging": { "next_cursor": "4821", "has_more": true }
}

Pass next_cursor back as ?cursor= and stop when has_more is false. Don't build a cursor yourself, and don't reach for ?page= — it was replaced so that Projects pages the same way as the rest of this API.

Retrying a write

Every write here — create, update, transition, comment — accepts an Idempotency-Key header. See Idempotency.

It matters most on exactly this surface. A first import is hundreds of writes in a loop — one was reported as 18 sprints, 146 stories and 670 tasks — and any one of them can time out after the row was written. Without the header, the retry files a second copy.

Things to know

  • Everything is nested under the project. A parent or sprint from another project is refused rather than silently accepted, so a task cannot end up hanging off an epic in a project it does not belong to.
  • An API key is not a person. Work created through the API records no user as its author, and a comment is signed with your key's name unless you pass an author. Nothing is attributed to a colleague who did not do it.
  • Internal notes are not returned. Comment reads give you the public thread only.

Full reference

Every endpoint, parameter and field: Projects in the API Reference.