API
Deleg8 speaks JSON over HTTPS. Everything the app does is in the API. The full description is an OpenAPI 3.1 document at /api/v1/openapi.json, built from the routes the server really serves; the table below is the published contract (deleg8/packages/contract).
API keys
An owner or admin makes a key in Admin → API keys. It is shown once. Send it as Authorization: Bearer d8k_… on /api/v1; no cookie, no safety code. A key acts as one team role (owner or admin; member keys are not offered yet), never higher than the person who made it, and its scopes narrow it further: read:<object>, write:<object> (objects: clients, users, boards, tickets, requests, files, and each custom object as obj_<key>, e.g. read:obj_locations), read:*, write:* and admin. Each operation in the OpenAPI document names the scope it needs. A key gets 600 calls a minute and 20 a second, told in the RateLimit-* headers; a POST may carry Idempotency-Key. Every call made with a key is logged. Revoke a key and it stops at once.
Webhooks
In Admin → Webhooks, an owner or admin points Deleg8 at an https address and picks events: <object>.created, .updated, .deleted, ticket.status_changed, request.created, request.reply, update.published, user.invited, user.deactivated; a custom object's records as obj_<key>.created (and so on), or record.created for all of them. Each delivery is a JSON POST signed with the endpoint's secret: Deleg8-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of "t.body">. Check it, and refuse a t more than five minutes old. A delivery that does not get a 2xx within 10 seconds is tried again over about 24 hours; an endpoint that keeps failing is turned off. Only public https addresses are allowed, checked at every send.
Doors
| Door | Who | How |
|---|---|---|
/api/v1 | You, in a browser | The sign-in cookie, plus x-deleg8-csrf on every change |
/api/v1 | Your tools | An API key: Bearer d8k_… |
/tickets/v1 | A computer or script with a work token (Settings → Work tokens) | Bearer stt_… and x-ticket-session |
A list you have no role on answers 404, never 403, so nothing reveals that it exists. Errors are { "error": "<a sentence>", "code": "<code>" }. Live updates come from GET <door>/events as server-sent events, carrying ticket and list ids but never ticket text.
Routes
| Route | Doors | Who | Body | Answer |
|---|---|---|---|---|
GET /admin/api-keys | /api/v1 | person | { keys: [{ id, name, last4, role, scopes, clients, createdBy, createdAt, expiresAt, lastUsedAt, lastUsedFrom, revokedAt }], objects: [{ key, name, plural }] } (team owner or admin; a key needs `admin`; objects: custom objects a scope may name) | |
POST /admin/api-keys | /api/v1 | person | { name, role: owner | admin, scopes: [read:<object> | write:<object> | read:* | write:* | admin], days?: 1..730 } (team owner or admin; role no higher than yours: 403 role_too_high; a key makes only keys no stronger than itself) | { id, name, last4, role, scopes, ..., token: "d8k_..." (shown once) } (201). 400 bad_name | bad_role | bad_scopes | bad_days | member_keys_later (no member keys yet); 409 too_many (50 live) |
DELETE /admin/api-keys/:id | /api/v1 | person | { ok } (revoked at once; team owner or admin; an owner key only by an owner: 403 role_too_high; 404 not_found) | |
GET /admin/api-keys/:id/calls | /api/v1 | person | { calls: [{ at, method, path, status, ip }], next: cursor | null } (?limit=1..200 (50)&cursor=<next>; newest first, kept 90 days; team owner or admin) | |
GET /admin/setup | /api/v1 | person | { warnings: string[] } (what the operator must fix, the start log's WARNING lines, e.g. DELEG8_OWNER_EMAILS unset on a public install; [] when nothing; team owner or admin) | |
GET /admin/webhooks | /api/v1 | person | { webhooks: [{ id, url, events, description, enabled, disabledWhy, createdAt, createdBy, lastDelivery: { state, at } | null }], events: string[] } (events: the names an endpoint may subscribe to; team owner or admin) | |
POST /admin/webhooks | /api/v1 | person | { url, events: [<event> | "*"], description? } (team owner or admin; url: public https only) | Webhook with secret: "whsec_..." (shown once) (201). 400 bad_events | bad_url | private_address | dns; 409 too_many (20) |
PATCH /admin/webhooks/:id | /api/v1 | person | { url?, events?, description?, enabled? } (team owner or admin; turning on one whose maker is gone makes you its maker) | Webhook (enabled: true clears its failure count; 400 bad_events | bad_url; 404 not_found) |
DELETE /admin/webhooks/:id | /api/v1 | person | { ok } (the endpoint and its delivery log go; team owner or admin; 404 not_found) | |
GET /admin/webhooks/:id/deliveries | /api/v1 | person | { deliveries: [{ id, event, type, state, tries: [{ at, status, ms, error, answer }], nextAt, createdAt }], next: cursor | null } (?limit=1..200 (50)&cursor=<next>; newest first; team owner or admin; 404 not_found) | |
POST /admin/webhooks/:id/deliveries/:dlv/redeliver | /api/v1 | person | { delivery: "dlv_..." } (201) (a new delivery of the same event and body, queued now; team owner or admin; 404 not_found) | |
POST /admin/webhooks/:id/roll-secret | /api/v1 | person | { secret: "whsec_..." (shown once), oldUntil } (the old secret signs too for 24 hours; team owner or admin; 404 not_found) | |
POST /admin/webhooks/:id/test | /api/v1 | person | { delivery, ok, try: { at, status, ms, error, answer } } (sends a ping now and waits for the one try; team owner or admin; 404 not_found) | |
GET /auth/pending-link | /api/v1 | anyone | { email, provider: "st8n" } (signed out is fine: this browser's pending st8n link, 10 minutes; 404 no_pending; 404 not_found while st8n sign-in is off (DELEG8_ST8N_SIGNIN)) | |
GET /clients | /api/v1 | person | { clients: Client[] } (Client = { id, name, owner: { id, name }, portalHost, ownRequestsOnly, boards: [{ id, name, portal }], people (active users) }; Clients you own, and the team's by role: owner/admin all, a member those assigned) | |
POST /clients | /api/v1 | person | { name } (team owner or admin, or an account with no team role, which owns it; a member: 403 forbidden) | Client (201) (400 no_name; 409 too_many (500 per owner)) |
GET /clients/:id | /api/v1 | person | Client with people: [{ id, name, email, role, status, at }] (active or not), invites: [{ email, at, expiresAt }] (clients.view: its owner, team owner/admin, a member assigned; else 404 no_client) | |
PATCH /clients/:id | /api/v1 | person | { name?, portalHost?: one of DELEG8_CUSTOMER_HOSTS | null, ownRequestsOnly?: boolean } (clients.edit: team owner/admin or its own owner; portalHost only a team owner/admin on a team Client) | Client (400 no_name | bad_host | bad_own_only | empty; 409 host_taken; 403 forbidden) |
DELETE /clients/:id | /api/v1 | person | { ok } (clients.edit; only with no boards: 409 has_boards; its client users and invites go, their accounts and requests stay) | |
GET /clients/:id/audit | /api/v1 | person | { entries: AuditEntry[] } (?limit= (100, at most 500); that Client's log; team owner or admin only (audit.view), a key needs `admin`; its own owner with no team role: 403) | |
POST /clients/:id/invite | /api/v1 | person | { email, role?: admin | requester | viewer } (clients.users.manage: team owner/admin or its own owner; role default requester) | { invited, mail: "sent" } | { invited, mail: "console", link } (no mail set up) | { invited, mail: "failed", link } (the send failed) (201) (link works once, 14 days; 400 bad_email | bad_role; 409 owner | team_invite | already; 429 rate_limited) |
DELETE /clients/:id/invites/:email | /api/v1 | person | { ok } (withdraws the invite, audit client.invite.withdrawn; ok: false when there was none; clients.users.manage) | |
POST /clients/:id/invites/:email/resend | /api/v1 | person | { invited, mail: "sent" | "console" | "failed", link? } (the same invite and role with a new link; the old link stops working; 404 no_invite; 429 rate_limited; clients.users.manage) | |
DELETE /clients/:id/people/:person | /api/v1 | person | { ok } (old name of DELETE /clients/:id/users/:person: removed from the Client, their requests stay; 404 no_user; clients.users.manage) | |
GET /clients/:id/requests | /api/v1 | person | { requests: [{ ticket, id, number, title, status, statusLabel, createdBy: { name }, lastCustomerAt, drafts, waiting }] } (every board of the Client, newest first, at most 500; clients.view) | |
GET /clients/:id/users | /api/v1 | person | { users: ClientUser[], invites: [{ email, role, at, expiresAt }] } (ClientUser = { id, name, email, role, status, lastActiveAt, at }; clients.view) | |
PATCH /clients/:id/users/:person | /api/v1 | person | { role: admin | requester | viewer } (clients.users.manage) | ClientUser (400 bad_role; 404 no_user) |
DELETE /clients/:id/users/:person | /api/v1 | person | { ok } (removed from the Client; their requests stay; no other active Client and no team role: their sessions end; 404 no_user; clients.users.manage) | |
POST /clients/:id/users/:person/deactivate | /api/v1 | person | ClientUser (status deactivated) (no other active Client and no team role: their sessions end at once; 404 no_user; clients.users.manage) | |
POST /clients/:id/users/:person/reactivate | /api/v1 | person | ClientUser (status active) (the team may turn on anyone, also one a client admin turned off; 404 no_user; clients.users.manage) | |
POST /clients/:id/users/:person/reset-link | /api/v1 | person | { mail: "sent" } | { mail: "console", link } | { mail: "none" } (no password here) (to the Client's own address, 24 hours, once; only a Client-only account: 409 not_client_account; 404 no_user; 429; 502 mail_failed) | |
GET /events | /api/v1 | person, session | text/event-stream of ChangeEvent (event: tickets); a st8n link as its person also gets `event: command` | |
POST /files | /api/v1 | person | the file's raw bytes (any content-type), ?name=log.pdf (team user; PNG, JPEG, GIF, WebP, HEIC or PDF, type from the bytes, 10 MB) | File (201) (waiting: attach it within 24 h with files: [id]; 400 no_file, 413 too_big, 415 bad_type, 429 too_many_uploads at 20 waiting, 429 rate_limited at 60 a minute) |
GET /files/:id | /api/v1 | person | the bytes (images inline, HEIC and PDF as a download; nosniff, sandbox CSP, no-store) (anyone on the ticket's list, or the uploader of a waiting file; else 404 no_file) | |
GET /invites | /api/v1 | person | { invites: [{ list, name, role, from }] } (waiting for this person's email) | |
DELETE /invites/:list | /api/v1 | person | { ok } (the invited person declines) | |
POST /invites/:list/accept | /api/v1 | person | { ok } (the person joins the list; their sessions take its tickets only after PUT /lists/:id/work-on) | |
GET /links | /api/v1 | person | { links: St8nLink[] } | |
POST /links | /api/v1 | person | { name, days?, kind?: "st8n" } | { id, token: "d8l_..." (shown once), name, kind, ... } (201) |
DELETE /links/:id | /api/v1 | person | { ok } (that st8n's sessions' claims go back to open) | |
GET /lists | /api/v1 | person, session | { lists: List[] } (?archived=1 adds archived) | |
POST /lists | /api/v1 | person | { name, about? } | List |
GET /lists/:id | /api/v1 | person, session | List (with its fields) | |
PATCH /lists/:id | /api/v1 | person | { name?, about?, archived? } (list owner) | List |
DELETE /lists/:id | /api/v1 | person | { confirm: <the list's name> } (list owner; not the Inbox; every ticket on it goes too) | { ok, id, tickets: <how many went> } |
GET /lists/:id/fields | /api/v1 | person, session | { fields: Field[] } | |
POST /lists/:id/fields | /api/v1 | person | { name, kind: FieldKind, options?: [{ label, color? }] } (list owner) | Field (201) |
PATCH /lists/:id/fields/:field | /api/v1 | person | { name?, hidden? } (list owner) | Field |
DELETE /lists/:id/fields/:field | /api/v1 | person | { ok, cleared: <tickets that lost a value> } (list owner) | |
POST /lists/:id/fields/:field/options | /api/v1 | person | { label, color? } (list owner; a dropdown) | Field (201) |
PATCH /lists/:id/fields/:field/options/:option | /api/v1 | person | { label?, color?, retired? } (list owner; a rename shows on every ticket) | Field |
PUT /lists/:id/fields/:field/options/order | /api/v1 | person | { ids: [every value id, in order] } (list owner) | Field |
PUT /lists/:id/fields/order | /api/v1 | person | { ids: [every field id, in order] } (list owner) | { fields: Field[] } |
POST /lists/:id/invite | /api/v1 | person | { email, role: manager | work | view } (owner; a manager may invite as work or view) | { ok, invited?: email, person?: id, people, invites, emailed, note? } (everyone invited by email, known or not, waits until they accept) |
DELETE /lists/:id/invites/:email | /api/v1 | person | { people, invites } (the owner withdraws an invite) | |
GET /lists/:id/people | /api/v1 | person, session | { people: ListPerson[], invites: Invite[] } (emails and invites: the list's owner only; others see names) | |
PUT /lists/:id/people/:person | /api/v1 | person | { role: manager | work | view } (owner; a manager may set work or view) | { people, invites } |
DELETE /lists/:id/people/:person | /api/v1 | person | { people, invites } (their claims there end, the list leaves their tokens) | |
GET /lists/:id/requests | /api/v1 | person | { requests: [{ ticket, id, number, title, status, statusLabel, createdBy: { name }, lastCustomerAt, drafts, waiting }] } (anyone on the list; newest first, at most 500; for badges) | |
PUT /lists/:id/work-on | /api/v1 | person | { on: boolean } | { ok, workOn } (a shared person's sessions take (or stop taking) tickets from this list) |
GET /me | /api/v1 | person, session | Me | |
GET /objects | /api/v1 | person | { objects: Object[] } (any team user; built-ins first: clients, users, boards, tickets) | |
POST /objects | /api/v1 | person | { name, plural?, icon?, portal? } (objects.manage: team owner or admin; icon one short word, default box) | Object (201) (key made once from the plural, obj_<plural>, never changes; 409 too_many at 50 custom objects) |
PATCH /objects/:id | /api/v1 | person | { name?, plural?, icon?, portal?, archived? } (objects.manage; a custom object only, 409 builtin; another key is 400 key_fixed) | Object |
DELETE /objects/:id | /api/v1 | person | { ok } (objects.manage; a custom object with no records: 409 has_records, archive it instead; 409 builtin) | |
GET /objects/:id/fields | /api/v1 | person | { fields: Field[] } (any team user; ?board=L-n on tickets adds that board's own fields) | |
POST /objects/:id/fields | /api/v1 | person | { name, type: FieldKind, board?, options?: [{ label, color? }], required?, default?, help?, portal?: hidden | read | edit, target?, currency? } (objects.manage; target for record, currency for money) | Field (201) (409 taken: one name per ticket or object; board fields: text, number, date, yesno, dropdown only) |
PATCH /objects/:id/fields/:field | /api/v1 | person | { name?, required?, default?, help?, portal?, hidden?, archived? } (objects.manage; the type never changes: 400 type_fixed) | Field |
POST /objects/:id/fields/:field/options | /api/v1 | person | { label, color? } (objects.manage; a dropdown or multi field, else 400 not_dropdown) | Field (201) (409 taken; at most 50 values) |
PATCH /objects/:id/fields/:field/options/:option | /api/v1 | person | { label?, color?, retired? } (objects.manage; a rename shows on every record) | Field |
PUT /objects/:id/fields/:field/options/order | /api/v1 | person | { ids: [every value id, in order] } (objects.manage) | Field |
PUT /objects/:id/fields/order | /api/v1 | person | { ids: [every one of the object's own field ids, in order] } (objects.manage; board fields keep theirs) | { fields: Field[] } |
GET /objects/:id/records | /api/v1 | person | { records: Record[], next: <skip> | null } (records.read; a custom object, else 409 builtin; ?q=&client=&f.<F-n>=<value|none|any>&sort=f.<F-n>|-f.<F-n>&limit=&skip=&archived=1) | |
POST /objects/:id/records | /api/v1 | person | { client?, fields?: { <name or id>: value } } (records.write: a member only for assigned Clients or none) | Record (201) (defaults filled; 400 required, 400 bad_field_value; 409 archived object) |
GET /objects/:id/records/:rid | /api/v1 | person | Record (records.read, else 404 no_record); on clients, users or boards: { id, object, title, fields } | |
PATCH /objects/:id/records/:rid | /api/v1 | person | { client?, fields?: { <name or id>: value | null }, archived? } (records.write on the old and the new Client; on clients/users/boards only fields, by clients.edit / team.users.role / board owner) | Record; on a built-in { id, object, title, fields } (tickets: 409 builtin, use PATCH /tickets/:id) |
DELETE /objects/:id/records/:rid | /api/v1 | person | { ok } (records.write; a custom object's record only) | |
GET /openapi.json | /api/v1 | anyone | OpenAPI 3.1 document of every /api/v1 route (public, no sign-in; someone on the team, or a key, also gets this team's custom objects it may read) | |
GET /people | /api/v1 | person, session | { people: [{ id, name, email }] } (people you already share with; never a directory) | |
POST /portal/files | /api/v1 | client | raw bytes, any content-type (?name=photo.jpg); PNG, JPEG, GIF, WebP, HEIC or PDF by the bytes, 10 MB | File (201): { id, name, type, size, image }, waiting 24 h to be sent with a request or reply (any client user; 413 too_big, 415 bad_type, 429 too_many_uploads at 20 waiting) |
GET /portal/files/:id | /api/v1 | client | the bytes (nosniff, sandbox CSP; images inline, HEIC/PDF as downloads): your own waiting upload, or a file on a request you may read, sent with it, a reply or a PUBLISHED update; else 404 no_file | |
GET /portal/invite | /api/v1 | anyone | { email, client: { name }, from: { name }, account: "new" | "existing", role: admin | requester | viewer } (public, ?token=d8c_...; 404 no_invite when used, expired, withdrawn or another Client's address; 429 per IP) | |
POST /portal/join | /api/v1 | anyone | { token, name?, password } (new account) | { token } (signed in as the proven holder of the invited email) | { person: { id, name, email }, csrfToken } (public; joins the Client with the invite's role, signs a new account in; 404 no_invite, 400 bad_password, 409 account_exists | other_account; JSON only: 415 json_only; from another site: 403 cross_site) |
GET /portal/me | /api/v1 | client | { person: { id, name, email }, csrfToken, clients: [{ id, name, owner: { name }, role, ownRequestsOnly, boards: [{ id, name }] }] } (on a Client's own address: that Client only; 403 not_client) | |
GET /portal/objects | /api/v1 | client | { objects: [{ id, name, plural, icon, fields: [{ id, name, type, access: read | edit, required, help, options? }] }] } (?client=; portal objects only, never a hidden field; 404 no_client) | |
GET /portal/objects/:id/records | /api/v1 | client | { records: PortalRecord[] } (?client=; that Client's own, newest first, up to 500; an object not in the portal is 404 no_object) | |
POST /portal/objects/:id/records | /api/v1 | client | { client, fields?: { <name or id>: value } } (client admin or requester; edit fields only, another is 400 not_editable) | PortalRecord (201) (viewer: 403 forbidden) |
GET /portal/objects/:id/records/:rid | /api/v1 | client | PortalRecord = { id, title, fields: { <name>: { type, value, option?, options?, access } }, createdAt, updatedAt } (?client=; another Client's: 404 no_record) | |
PATCH /portal/objects/:id/records/:rid | /api/v1 | client | { client, fields: { <name or id>: value } } (client admin or requester; edit fields only, else 400 not_editable) | PortalRecord |
GET /portal/profile | /api/v1 | client | Profile = { id, name, email, jobTitle, phone, timeZone, photo: url | null, hasPassword, canChangeEmail, events: [{ id: update_published, label, email, push }] } | |
PATCH /portal/profile | /api/v1 | client | { name?, jobTitle?, phone?, timeZone?, photo?: <your uploaded image id> | null } (photo: PNG/JPEG/WebP/GIF up to 3 MB from POST /portal/files) | Profile (400 bad_name, bad_phone, bad_time_zone, bad_photo) |
GET /portal/profile/devices | /api/v1 | client | { devices: [{ id, current, label: "Safari on iPhone", where: "203.0.113.x", signedInAt, lastSeenAt }] } (your own signed-in browsers) | |
DELETE /portal/profile/devices/:id | /api/v1 | client | { ok, current } (signs that browser out; the current one clears its cookie; 404 no_device) | |
POST /portal/profile/devices/sign-out-others | /api/v1 | client | { ok, count } (every browser but this one) | |
POST /portal/profile/email | /api/v1 | client | { email, password } | { ok, mail: sent | console } (a 24 h link goes to the NEW address; nothing changes yet; 400 bad_email, bad_credentials, same_email, st8n_email; 429 after 3 an hour) |
POST /portal/profile/email/confirm | /api/v1 | client | { token } (the account that asked, signed in) | { ok, email } (every other browser is signed out; the old address is told; 400 bad_token, 409 email_taken) |
PUT /portal/profile/notify | /api/v1 | client | { update_published?: { email?: boolean, push?: boolean } } (unknown events ignored; default on) | Profile |
GET /portal/profile/photo/:person | /api/v1 | client | the photo bytes (nosniff, sandbox CSP) (your own, or of someone you share a Client with: its owner, its active users, the team on it; else 404 no_photo, as for no photo) | |
GET /portal/push/key | /api/v1 | client | { publicKey: string | null } (null: push is not set up here) | |
POST /portal/push/subscribe | /api/v1 | client | { endpoint, keys: { p256dh, auth } } (endpoint: https on a known push service) | { ok } (at most 10 devices, the oldest go; 404 push_off, 400 bad_endpoint | bad_keys, 409 endpoint_taken: another account's browser) |
POST /portal/push/unsubscribe | /api/v1 | client | { endpoint } | { ok } (your own subscription for that endpoint goes; none is still ok) |
GET /portal/requests | /api/v1 | client | { requests: Request[] } (?client=&board=&status=open | closed | all, default open; newest first; requesters see only their own when the Client's own-requests switch is on) | |
POST /portal/requests | /api/v1 | client | { board?, title, details?, urgency?: low | normal | high | urgent, files?: [id], fields?: { <name>: value } } (board: needed when you see more than one; fields: edit ones only) | Request (201) (client admin or requester; viewer 403 forbidden; 400 pick_board | no_title | bad_urgency | not_editable, 404 no_list; mails and pushes the team) |
GET /portal/requests/:id | /api/v1 | client | { request: Request, thread: Message[] } (published updates and client replies only, oldest first; never drafts, notes or internal files; one you may not read is 404 no_request) | |
POST /portal/requests/:id/close | /api/v1 | client | Request (status closed) (client admin any, requester their own; viewer 403 forbidden; the ticket itself is left as it is; already closed: the same answer) | |
POST /portal/requests/:id/replies | /api/v1 | client | { text?, files?: [id] } (text up to 10,000; one of the two needed, else 400 no_text) | Message (201) (client admin or requester; viewer 403 forbidden; 409 closed; clears Waiting on you; mails and pushes the team) |
GET /portal/users | /api/v1 | client | { users: ClientUser[], invites: [{ email, role, at, expiresAt }] } (?client=; client admin only (portal.users.view), else 403 forbidden; not one of yours: 404 no_client) | |
PATCH /portal/users/:person | /api/v1 | client | { client, role: admin | requester | viewer } (client admin) | ClientUser (400 bad_role, 404 no_user, 409 last_admin: the Client keeps one active client admin) |
DELETE /portal/users/:person | /api/v1 | client | { ok } (?client=; client admin; removed from that Client, signed out when they have no other access; 409 last_admin, 404 no_user) | |
POST /portal/users/:person/deactivate | /api/v1 | client | { client } (client admin) | ClientUser (status deactivated; signed out when they have no other access; 409 last_admin) |
POST /portal/users/:person/reactivate | /api/v1 | client | { client } (client admin) | ClientUser (403 team_deactivated: the team turned them off, only the team turns them on) |
POST /portal/users/:person/reset-link | /api/v1 | client | { client } (client admin) | { mail: sent } | { mail: console, link } (no mail set up) | { mail: withheld } (no mail set up and the user is also in another Client: a client admin is not handed that link; it is only logged) | { mail: none } (to the Client's address; 409 not_client_account: they reset their own; 429; 502 mail_failed) |
GET /portal/users/audit | /api/v1 | client | { entries: [{ id, at, by: { name }, action, target: { kind, name }, client: { id, name }, detail }] } (?client=&limit=, default 100, at most 500; client admin (portal.users.view); no ids of the team) | |
POST /portal/users/invite | /api/v1 | client | { client, email, role?: admin | requester | viewer } (client admin; default requester) | { invited, mail: sent } | { invited, mail: console | failed, link } (201) (14-day link; 400 bad_email | bad_role, 409 owner | team_invite | already, 429) |
DELETE /portal/users/invites/:email | /api/v1 | client | { ok } (?client=; client admin withdraws; ok false when there was none) | |
POST /portal/users/invites/:email/resend | /api/v1 | client | { client } (client admin) | { invited, mail: sent | console | failed, link? } (the same invite and role, a new link; the old one stops working; 404 no_invite) |
GET /profile | /api/v1 | person | Profile = { id, name, email, jobTitle, phone, timeZone, photo: url | null, hasPassword, canChangeEmail, events: [{ id: new_request | customer_reply, label, email, push }] } | |
PATCH /profile | /api/v1 | person | { name?, jobTitle?, phone?, timeZone?, photo?: <your uploaded image id> | null } (photo: PNG/JPEG/WebP/GIF up to 3 MB from POST /files) | Profile (400 bad_name, bad_phone, bad_time_zone, bad_photo) |
GET /profile/devices | /api/v1 | person | { devices: [{ id, current, label: "Safari on iPhone", where: "203.0.113.x", signedInAt, lastSeenAt }] } (your own signed-in browsers) | |
DELETE /profile/devices/:id | /api/v1 | person | { ok, current } (signs that browser out; the current one clears its cookie; 404 no_device) | |
POST /profile/devices/sign-out-others | /api/v1 | person | { ok, count } (every browser but this one) | |
POST /profile/email | /api/v1 | person | { email, password } | { ok, mail: sent | console } (a 24 h link goes to the NEW address; nothing changes yet; 400 bad_email, bad_credentials, same_email, st8n_email; 429 after 3 an hour) |
POST /profile/email/confirm | /api/v1 | person | { token } (the account that asked, signed in) | { ok, email } (every other browser is signed out; the old address is told; 400 bad_token, 409 email_taken) |
PUT /profile/notify | /api/v1 | person | { new_request?: { email?, push? }, customer_reply?: { email?, push? } } (booleans; unknown events ignored; default on) | Profile |
GET /profile/photo/:person | /api/v1 | person | the photo bytes (nosniff, sandbox CSP) (a team user: anyone's; an account of its own: its own and those it shares a Client with; else 404 no_photo, as for no photo) | |
GET /push/key | /api/v1 | person | { publicKey: string | null } (null: push is not set up here) | |
POST /push/subscribe | /api/v1 | person | { endpoint, keys: { p256dh, auth } } (endpoint: https on a known push service) | { ok } (at most 10 devices, the oldest go; 404 push_off, 400 bad_endpoint | bad_keys, 409 endpoint_taken: another account's browser) |
POST /push/unsubscribe | /api/v1 | person | { endpoint } | { ok } (your own subscription for that endpoint goes; none is still ok) |
GET /sessions | /api/v1 | person, session | { sessions: Session[], st8ns: St8nPresence[] } (your own connected st8n computers' sessions; empty when none is on; with st8n off always { sessions: [], st8ns: [] }) | |
GET /team/audit | /api/v1 | person | { entries: AuditEntry[] } (?user=&client=&limit= (100, at most 500); newest first; team owner or admin, a key needs `admin`) | |
POST /team/invite | /api/v1 | person | { email, role?: admin | member, clients?: [Client id] } (team owner or admin; role default member; clients: the team's Clients, for a member) | { invited, mail: "sent" } | { invited, mail: "console" | "failed", link } (201) (applied when that address signs in; 400 bad_email | bad_role | bad_clients; 409 client_account | already) |
DELETE /team/invites/:email | /api/v1 | person | { ok } (withdraws the invite; ok: false when there was none; team owner or admin) | |
POST /team/invites/:email/resend | /api/v1 | person | { invited, mail: "sent" | "console" | "failed", link? } (the same invite, role and Clients, mailed again; 404 no_invite; 429 rate_limited (20 an hour); team owner or admin) | |
GET /team/users | /api/v1 | person | { users: TeamUser[], invites: [{ email, role, clients, at }] } (?q= matches name or email; TeamUser = { id, name, email, role, status, clients: [{ id, name }], lastActiveAt, createdAt }; team owner or admin) | |
PATCH /team/users/:id | /api/v1 | person | { role?: owner | admin | member, clients?: [Client id] } (team owner; an admin only on a non-owner and never to owner; clients: a member's assigned Clients) | TeamUser (an owner or admin made a member hands their Clients and boards to you; 400 bad_role | bad_clients; 404 no_user; 409 last_owner) |
DELETE /team/users/:id | /api/v1 | person | { ok } (off the team: no role, no assigned Clients, no shares on the team's boards; an owner's or admin's Clients and boards move to you, their sessions and keys end; 409 last_owner; 404 no_user) | |
POST /team/users/:id/deactivate | /api/v1 | person | TeamUser (status deactivated) (sign-in blocked, every session ends at once, their webhooks turn off; an admin only on a non-owner; 409 last_owner; 404 no_user) | |
POST /team/users/:id/reactivate | /api/v1 | person | TeamUser (status active) (team owner; an admin only on a non-owner; 404 no_user) | |
POST /team/users/:id/reset-link | /api/v1 | person | { mail: "sent" } | { mail: "console", link } | { mail: "none" } (st8n sign-in, no password here) (24 hours, once; an admin never for an owner; 404 no_user; 429 rate_limited; 502 mail_failed) | |
GET /tickets | /api/v1 | person, session | { tickets: Ticket[], next: n | null } (?list=&status=open,working&assignee=&label=&q=&batch=&machine=&mine=1&before=<n>&limit=&sort=queue | f.<F-n> | -f.<F-n>&f.<F-n>=<filter>) filter: dropdown O-1,O-2 | yes/no yes|no | text: contains | number/date a..b, a.., ..b | none | any | |
POST /tickets | /api/v1 | person | { list?, title, details?, acceptance?, where?, assignee?, priority?, labels?, fields?: { <name or id>: value }, parent? } (list default: your Inbox; parent: a new sub-ticket on the parent's list, docs/SUBTICKETS.md) | Ticket (201) |
GET /tickets/:id | /api/v1 | person, session | Ticket with timeline, can, parent, children and related (docs/SUBTICKETS.md) | |
PATCH /tickets/:id | /api/v1 | person | { title?, details?, acceptance?, where?, priority?, labels?, list?, fields?: { <name or id>: value | null } } (fields merge; null clears) | Ticket |
DELETE /tickets/:id | /api/v1 | person | { confirm: <the ticket id> } (list owner or manager, or whoever added it; not while a session holds it) | { ok, id } |
POST /tickets/:id/assign | /api/v1 | person | { assignee, st8n? } (st8n: the link id a session: assignee runs behind) | Ticket |
POST /tickets/:id/block | /api/v1 | session, person | { why } | Ticket |
POST /tickets/:id/cancel | /api/v1 | person | { why? } | Ticket |
POST /tickets/:id/claim | /api/v1 | session | { folder? } | Ticket |
GET /tickets/:id/customer | /api/v1 | person | { request: Request | null, thread: TeamMessage[] } (anyone on the list; null: not a client request; the thread includes drafts) | |
POST /tickets/:id/customer/updates | /api/v1 | person | { text, waiting?, files?: [id] } (list owner or manager; waiting: the request waits on the Client once published) | TeamMessage (201) (a draft; 409 not_a_request, 400 no_text) |
PATCH /tickets/:id/customer/updates/:uid | /api/v1 | person | { text?, waiting?, files? } (list owner or manager; files replaces the draft's files) | TeamMessage (409 published: write a new one) |
DELETE /tickets/:id/customer/updates/:uid | /api/v1 | person | { ok } (list owner or manager; a draft only, its files go too; 409 published) | |
POST /tickets/:id/customer/updates/:uid/publish | /api/v1 | person | TeamMessage (list owner or manager; the client users who may read the request see it; while its board is shown, mail and push go to those who chose them; again: no-op) | |
POST /tickets/:id/done | /api/v1 | session, person | Evidence | Ticket |
GET /tickets/:id/files | /api/v1 | person | { files: (File & { on: request | reply | update | ticket, message, published, by: { name }, at })[] } (anyone on the list; 404 not_found) | |
POST /tickets/:id/files | /api/v1 | person | { files: [id], note? } (owner, manager or work on the list; at most 5, each your own waiting upload) | { files: File[] } (201) (internal: never shown to the Client; a timeline note says so; 403 read_only; 404 not_found) |
DELETE /tickets/:id/files/:fid | /api/v1 | person | { ok } (whoever added it, or the list owner or a manager; 403 not_editor; 404 not_found | no_file; 409 sent: the Client already has it) | |
POST /tickets/:id/note | /api/v1 | session, person | { text } | Ticket |
PUT /tickets/:id/parent | /api/v1 | person | { parent: "T-n" } | Ticket with parent, children, related (one level, same list; docs/SUBTICKETS.md; 404 not_found) |
DELETE /tickets/:id/parent | /api/v1 | person | Ticket with parent, children, related (no longer a sub-ticket) | |
POST /tickets/:id/related | /api/v1 | person | { ticket: "T-n" } | Ticket with parent, children, related (both ways; at most 20; 404 not_found) |
DELETE /tickets/:id/related/:other | /api/v1 | person | Ticket with parent, children, related | |
POST /tickets/:id/release | /api/v1 | session, person | { why? } (person: list owner) | Ticket |
POST /tickets/:id/renew | /api/v1 | session | { leaseUntil } | |
POST /tickets/:id/reopen | /api/v1 | person | { why? } | Ticket |
POST /tickets/:id/session | /api/v1 | person | {} (an open ticket you may work; your st8n connected and on) | { ticket, session: { id, name, machine } } (201): your st8n started a session told to work only this ticket; it claims it. 403 person_only (a session, a link or an API key: a person at the page only), 403 no_work; 404 st8n_off with st8n off; 409 no_st8n | not_open, or what your st8n said |
POST /tickets/:id/start | /api/v1 | session | Ticket | |
GET /tickets/:id/track | /api/v1 | person, session | { track: TicketTrack, timeline: Change[] } (anyone who sees the ticket; track: opened, claimed, done, review, merged, verified, times...; timeline oldest first) | |
POST /tickets/:id/track | /api/v1 | person, session | { type: merged | reviewed | verified, key, at?, text? } (work rights on the list; key: one mark per key) | { ok, added } (added false: that key was already written; verified only by the list owner or a manager: 403 not_manager; 403 read_only; at held between the ticket's making and now) |
POST /tickets/ask | /api/v1 | person, session | { question, list? } (question under 1,000 chars; list: one board, else every list you see) | { say, cites, looked, refused?, model? } (read only; 503 helper_off without a model; 429 rate_limited at 30 an hour per person, every door, link and session together, helper_cap, list_rate_limited) |
POST /tickets/bulk | /api/v1 | person | { list?, text, defaults?: { where, assignee, priority, labels } } | { batch, tickets } (201) |
GET /tickets/changes | /api/v1 | person, session | { since, changes: [{ ticket, title, at, type, from, to, text, who }] } (newest first; ?since=<ms or date, default 24 h ago>&list=&limit= up to 5000, default 500; 10 a minute per person across reports, changes and workers: 429 rate_limited) | |
GET /tickets/imported/:site/:id | /api/v1 | person, session | Ticket with can and importedFrom: { site, id } (the ticket st8n imported under that old id; else 404) | |
POST /tickets/next | /api/v1 | session | { list?, labels?, folder?, ties? } | { ticket: Ticket | null } (claimed for this session); with ties: true it claims nothing: { ticket: null, ties: Ticket[] } (the tied first candidates, at most 20) |
GET /tickets/proposals | /api/v1 | person | { proposals: Proposal[] } (?status=waiting|approved|declined; the lists you own) | |
POST /tickets/proposals | /api/v1 | session | { kind: create|edit|close|cancel|reopen|move|assign, ticket?, list?, changes: { title?, details?, acceptance?, where?, priority?, labels?, fields?, summary? (close), list? (move), assignee? (assign) }, why? } (10 an hour per session) | Proposal (201) (P-n, waiting: nothing changes until the list owner approves). The ticket's own assignee editing or closing it: { applied: true, kind, ticket } at once, on the timeline (T-330) |
GET /tickets/proposals/:id | /api/v1 | person | Proposal | |
POST /tickets/proposals/:id/approve | /api/v1 | person | { edits?: { title?, details?, acceptance?, where?, priority?, labels?, list?, fields? } } (list owner) | { proposal, ticket } |
POST /tickets/proposals/:id/decline | /api/v1 | person | { why? } (list owner) | Proposal |
POST /tickets/proposals/approve | /api/v1 | person | { ids: [P-n, ...] } (list owner; more than 3 needs a fresh sign-in) | { approved: { proposal, ticket }[] } |
POST /tickets/propose | /api/v1 | session | { list?, title, details?, acceptance?, where?, priority?, labels?, fields? } (at most 10 an hour per session) | Proposal (201) (P-n, status waiting: not a ticket until approved) |
GET /tickets/reports | /api/v1 | person, session | { at, from, to, filters, totals, perDay, times, workers, teams, stuck, tracks? } (?list=&days=&from=&to=&tz=&worker=&team=&label=&status=&tracks=1; &format=csv&part=perDay|workers|teams|stuck|tickets: text/csv; 10 a minute per person across reports, changes and workers: 429 rate_limited) | |
GET /tickets/workers | /api/v1 | person, session | { workers: [{ worker, name, machine, team, tickets, done, sentBack, blocked, released, holding, busyMs, idleMs, idleNowMs, avgClaimToDoneMs, firstSeen, lastSeen }] } (?list=&days=&from=&to=; 10 a minute per person across reports, changes and workers: 429 rate_limited) | |
GET /tokens | /api/v1 | person | { tokens: WorkToken[] } | |
POST /tokens | /api/v1 | person | { computer, lists, days? } | { id, token: "stt_..." (shown once), ... } (201) |
DELETE /tokens/:id | /api/v1 | person | { ok } | |
POST /block/:id | /tickets/v1 | session | as /tickets/:id/block | |
POST /claim/:id | /tickets/v1 | session | as /tickets/:id/claim | |
POST /done/:id | /tickets/v1 | session | as /tickets/:id/done | |
GET /get/:id | /tickets/v1 | session | Ticket | |
GET /lists | /tickets/v1 | session | { lists: List[] } (the token's) | |
POST /next | /tickets/v1 | session | { list?, labels?, folder? } | { ticket } |
POST /note/:id | /tickets/v1 | session | as /tickets/:id/note | |
POST /release/:id | /tickets/v1 | session | as /tickets/:id/release | |
POST /renew/:id | /tickets/v1 | session | as /tickets/:id/renew | |
POST /start/:id | /tickets/v1 | session | as /tickets/:id/start |