# 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`](https://api.gantt-to.work/v1/openapi.json). This document, as markdown: [`/llms.txt`](https://api.gantt-to.work/llms.txt). ## Authentication Send an API key on every request: ``` Authorization: Bearer gt_live_xxxxxxxxxxxxxxxxxxxxxxxx ``` Create a key at [app.gantt-to.work](https://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: ```json {"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: ```json {"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 ```bash export GT=gt_live_xxxxxxxxxxxxxxxxxxxxxxxx export API=https://api.gantt-to.work ``` Create an organization and a project: ```bash 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: ```bash 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`: ```bash 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.