Skip to content

gantt-to API v1

Gantt charts as data. Read or replace an entire chart in one request, and wire parents and dependencies with keys you choose rather than server-generated ids.

Base URL https://api.gantt-to.work
Format JSON, UTF-8. Send Content-Type: application/json
Dates YYYY-MM-DD
Timestamps RFC3339, UTC
Ids UUID strings
Max request body 4 MB

Machine-readable: /v1/openapi.json. This document, as markdown: /llms.txt.

Authentication

Send an API key on every request:

Authorization: Bearer gt_live_xxxxxxxxxxxxxxxxxxxxxxxx

Create a key at app.gantt-to.work under Settings → API keys. It is shown once, at creation, and cannot be recovered afterwards.

A key carries the full authority of the account that created it, with one exception: it cannot create or revoke keys. That requires a signed-in browser, so a leaked key cannot extend its own life. There is no header or scope that lifts it.

Requests without credentials to a non-public route return 401.

Errors

Every non-2xx response has this shape and no other:

{"error": {"code": "invalid_request", "message": "human readable", "field": "start_date"}}

field is present only when a single field is at fault.

code status meaning
invalid_request 400 malformed or rejected input
unauthorized 401 no valid credentials
plan_limit 402 an account limit would be exceeded
forbidden 403 authenticated, but the role is too low
not_found 404 no such resource, or none you may see
conflict 409 the request contradicts current state
rate_limited 429 too many requests; honour Retry-After
internal 500 server fault

Four behaviours worth knowing before you write a client:

404 also means "exists, but not for you." A private project you are not a member of returns 404, never 403, so existence never leaks. Do not retry a 404.

Unknown fields are rejected. {"colour": "red"} or {"startDate": "2026-09-01"} returns 400 rather than being ignored. Field names are snake_case, exactly as spelled here.

An unresolvable ref is 400, not 404. It is a mistake in your payload, not a missing resource.

Bulk writes are atomic. If any element is rejected, nothing is written. The message names the offending element by index:

{"error":{"code":"invalid_request","message":"tasks[3]: end_date is required","field":"end_date"}}

A failure on the first element omits the index and reads name is required, so parse the prefix when present rather than requiring it.

All 4xx responses are deterministic. The same request fails the same way.

Objects

Org         {id, slug, name, owner_id, role, created_at, updated_at}
            role is the caller's own role: owner | admin | member | viewer

Member      {user_id, email, name, role}

Project     {id, org_id, org_slug, slug, name, description, visibility,
             created_at, updated_at}
            visibility: private | public

Task        {id, ref, project_id, parent_id, name, notes, start_date, end_date,
             progress, color, assignee, is_milestone, sort_order}
            progress 0-100. end_date >= start_date. sort_order ascending.

Dependency  {id, project_id, predecessor_id, successor_id, type, lag_days}
            type: FS | SS | FF | SF. lag_days may be negative.

Gantt       {project: Project, tasks: [Task], dependencies: [Dependency]}

Write shapes:

TaskInput   {id?, ref?, parent_ref?, parent_id?, name, notes?, start_date,
             end_date, progress?, color?, assignee?, is_milestone?, sort_order?}

DepInput    {predecessor_ref? | predecessor_id?, successor_ref? | successor_id?,
             type?, lag_days?}

Give each dependency end exactly one of its _id or _ref form; both ends are required.

On PUT /gantt and POST /tasks:batch, a task with is_milestone: true may omit end_date; it is copied from start_date. POST /tasks requires it either way.

Required TaskInput fields depend on the route:

route required
PUT /gantt name, start_date, end_date on every task, and every other field you want kept
POST /tasks:batch name, start_date, end_date only on tasks that do not exist yet
POST /tasks name, start_date, end_date
PATCH /tasks/{id} nothing; send only what changes

Endpoints

Slugs are lowercase and hyphenated. Org slugs are globally unique; project slugs are unique within their org. A taken slug is 409.

Account

GET /healthz 200 {status, db}. No auth.
GET /v1/me 200 {user, orgs: [Org], limits}

Organizations

POST /v1/orgs {name, slug}201 {org}. You become owner.
GET /v1/orgs 200 {orgs: [Org]}
GET /v1/orgs/{orgID} 200 {org, members: [Member]}
PATCH /v1/orgs/{orgID} {name}200 {org}. admin+
DELETE /v1/orgs/{orgID} 204. owner only. Cascades.

Members

POST /v1/orgs/{orgID}/members {email, role}201 {member}. admin+
PATCH /v1/orgs/{orgID}/members/{userID} {role}200 {member}. admin+
DELETE /v1/orgs/{orgID}/members/{userID} 204. admin+, or a member removing themselves.

Invites take an email address. If no account has it, the membership attaches to a placeholder that the person claims when they first sign in with that address. No mail is sent; tell them yourself.

condition status
role is not exactly admin, member or viewer — case-sensitive, untrimmed 400
role is owner 400
only the owner may add, re-role or remove an admin 403
already a member; use PATCH 409
the org owner's own membership 409, immutable

Projects

POST /v1/orgs/{orgID}/projects {name, slug, description?, visibility?}201 {project}
GET /v1/orgs/{orgID}/projects 200 {projects: [Project]}
GET /v1/projects/{projectID} 200 {project}
PATCH /v1/projects/{projectID} any of {name, slug, description, visibility}200 {project}
DELETE /v1/projects/{projectID} 204. Cascades to tasks and dependencies.

Charts

GET /v1/projects/{projectID}/gantt 200 Gantt
PUT /v1/projects/{projectID}/gantt {tasks, dependencies?}200 Gantt. Replaces the chart.
POST /v1/projects/{projectID}/tasks:batch {tasks?, dependencies?}200 Gantt. Upserts. Needs at least one element.

Tasks and dependencies

POST /v1/projects/{projectID}/tasks TaskInput201 {task}
PATCH /v1/tasks/{taskID} partial TaskInput200 {task}
DELETE /v1/tasks/{taskID} 204. Cascades to children and edges.
POST /v1/projects/{projectID}/dependencies DepInput201 {dependency}
DELETE /v1/dependencies/{depID} 204

An edge is unique per (predecessor, successor); sending it again updates its type and lag_days. A cycle or a self-edge is 409, message dependency would create a cycle.

Public

GET /v1/public/{orgSlug}/{projectSlug}/gantt 200 Gantt when the project is public, else 404. No auth.

Writing a chart

PUT /gantt means the chart is exactly this. POST /tasks:batch means apply these changes.

They differ at the field level, not only the row level:

PUT /gantt POST /tasks:batch
task in the project, absent from the body deleted untouched
field omitted on a task in the body reset to default untouched
parent_ref / parent_id omitted moved to top level untouched
dependencies replaced wholesale upserted; none removed
omitting the dependencies key deletes every edge changes nothing

Under PUT, an omitted field on a task you did send is reset: notes and color and assignee to "", progress and sort_order to 0, is_milestone to false, ref to null, parent to top level. name, start_date and end_date are never reset because PUT requires them.

So a PUT task object must be complete. The safe pattern is GET /gantt, edit the objects you received, and send all of them back. PUT accepts and ignores the project key so that round trip works.

Deleting a task cascades to its children. Under PUT that only reaches children the body leaves out: a child the body does list is matched by ref or id, keeps its identity, and moves to top level when its parent goes.

Use tasks:batch for anything incremental. Use PUT only when you can state what every task in the project should look like.

Ref keys

ref is a caller-supplied string, unique within a project, nullable. It lets one request create a chart and wire its parents and dependencies without knowing any server-generated id.

field resolves to
ref declares this task's key
parent_ref the parent task
predecessor_ref, successor_ref the ends of an edge

Refs resolve against the request body first, then against refs already stored in the project. On a task, id wins over ref; with neither, the task is created.

Under PUT, omitting ref on a task matched by id sets it to null and loses the handle. Always send ref back.

Permissions

action required role
read a private project, its tasks and dependencies viewer
create, update or delete a project, task or dependency member
update the org; invite, re-role or remove members admin
add, re-role or remove an admin; delete the org owner
read a public project none

A role too low returns 403 where you can already see the resource, 404 where you cannot.

Limits

Accounts have limits on organizations, projects per organization, tasks per project, API keys and editors. Exceeding one returns 402 with a message naming the limit.

Viewers are free and unlimited on every plan. The editor seat count covers only the roles that can change something - owner, admin, member - so adding somebody who can read costs nothing. Charging to let a person look at a chart would leave "make it public" as the cheap way to show one, and an internal plan is not something to publish by accident.

Read the values that apply to you from GET /v1/me; -1 means unlimited. Do not hard-code them.

Limits on org-scoped resources are measured against the organization's owner, not the caller.

A 402 never means data was lost. It refuses one new thing. Anything that already exists stays readable and editable, including whatever sits above a limit after it changes. Retrying will not clear it.

While billing is closed the message does not name an upgrade. Paid plans are not on sale yet on this deployment, so a 402 says the limit cannot be raised today rather than telling you to buy a tier you cannot buy. GET /v1/plans reports available: false in that state, and every tier in it is purchasable: false. Read available before offering anyone a way to pay.

Rate limiting

Requests are rate limited, and the limit is the same on every plan. It is a safety net against a caller stuck in a retry loop, not a tier feature; no endpoint, capability or throughput is sold here.

Over the limit is 429 with the rate_limited code and a Retry-After header in seconds. Unlike a 402, a 429 does clear on its own: wait the stated delay and retry.

The budget is per credential, so one API key looping cannot spend another key's allowance. Requests with no credential are budgeted per address.

GET /healthz is never limited, so a throttled health check can never be mistaken for the service being down.

You are very unlikely to meet this. A whole chart is written in one call, so a client that needs hundreds of requests a second is repeating itself - and that is exactly what the limit is here to interrupt.

Quickstart

export GT=gt_live_xxxxxxxxxxxxxxxxxxxxxxxx
export API=https://api.gantt-to.work

Create an organization and a project:

curl -s -X POST $API/v1/orgs -H "Authorization: Bearer $GT" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Acme Inc","slug":"acme"}'
# -> 201 {"org":{"id":"3f2504e0-...","slug":"acme","role":"owner", ...}}

curl -s -X POST $API/v1/orgs/3f2504e0-.../projects -H "Authorization: Bearer $GT" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Apollo Launch","slug":"apollo"}'
# -> 201 {"project":{"id":"8f14e45f-...", ...}}

Write the whole chart in one request. No task ids appear anywhere; the refs carry the structure:

curl -s -X PUT $API/v1/projects/8f14e45f-.../gantt -H "Authorization: Bearer $GT" \
  -H 'Content-Type: application/json' \
  -d '{
        "tasks": [
          {"ref":"phase-1","name":"Phase 1","start_date":"2026-09-01","end_date":"2026-09-25","sort_order":1},
          {"ref":"design","parent_ref":"phase-1","name":"Design","start_date":"2026-09-01","end_date":"2026-09-07","progress":100,"sort_order":2},
          {"ref":"build","parent_ref":"phase-1","name":"Build","start_date":"2026-09-08","end_date":"2026-09-25","sort_order":3},
          {"ref":"launch","name":"Launch","start_date":"2026-09-30","is_milestone":true,"sort_order":4}
        ],
        "dependencies": [
          {"predecessor_ref":"design","successor_ref":"build"},
          {"predecessor_ref":"build","successor_ref":"launch","lag_days":2}
        ]
      }'
# -> 200 Gantt, ids assigned and both edges resolved

Add a task later without disturbing the rest — note tasks:batch, not PUT:

curl -s -X POST $API/v1/projects/8f14e45f-.../tasks:batch -H "Authorization: Bearer $GT" \
  -H 'Content-Type: application/json' \
  -d '{
        "tasks": [{"ref":"qa","name":"QA","start_date":"2026-09-26","end_date":"2026-09-29","sort_order":5}],
        "dependencies": [{"predecessor_ref":"build","successor_ref":"qa"}]
      }'
# -> 200 Gantt with five tasks; the other four keep every field

The same body sent as a PUT would have deleted those four.