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 |
TaskInput → 201 {task} |
PATCH /v1/tasks/{taskID} |
partial TaskInput → 200 {task} |
DELETE /v1/tasks/{taskID} |
204. Cascades to children and edges. |
POST /v1/projects/{projectID}/dependencies |
DepInput → 201 {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.