API guide for developers
How to read and write docket over its GraphQL API: lists, filters and pages, booking time, creating tickets, reading invoices, and what an error looks like.
This guide shows the calls most automations start with. How to switch the API on and create a key is in API and webhooks. Every example is checked against docket's schema whenever docket changes.
The basics
- One address:
POST https://yourworkspace.docket.rocks/graphqlwithAuthorization: Bearer dkt_…and a JSON body{ "query": "…", "variables": { … } }. - A key is a person. It sees and may do what its owner can in the app: a Project Manager's key
reads invoices, a Time Tracker's key sees its own time only. What the role doesn't allow comes
back as
nullor as an error, never as someone else's data. - The reference is the schema:
GET /graphql/schemareturns it as text, with a description on every query, every mutation and the objects they return. Load it into your GraphQL client for autocompletion. - Ids are UUIDs, the same in the API, in webhook events and in the app's addresses.
- Dates are
YYYY-MM-DDin your workspace's time zone. Durations are minutes. Money is net, in your workspace's currency, unless a field says gross.
Signing in
There is one way for your own integrations: a personal API key in the Authorization header.
No cookies, no login call, no token to renew. A key works until its owner deletes it or is
archived, or an admin switches the API off.
docket's own programs sign in differently: the web app with a session after e-mail, password and second factor, the Mac app with the same credentials, and AI apps through a consent screen (see Connect AI apps). Those ways belong to docket's clients and may change without notice. Don't build on them, and don't put a password into a script: a key can be deleted on its own and can never change users, settings or other keys.
Every query and every mutation with an example call and answer, readable without a key, is in the API reference.
Who am I
query {
viewer {
id
firstname
lastname
role
}
}
Lists: filter, order, pages
Every list takes the same arguments.
query Projects($search: String, $page: Int) {
total: projects(filter: { search: $search, archived: false }) {
count
}
projects(filter: { search: $search, archived: false }, orderBy: ["title"], perPage: 50, page: $page) {
edges {
node {
id
pnr
title
slug
customer {
id
company
}
}
}
}
}
- Totals come from a second field without
perPage(totalabove): on a paged list,countandsumonly see the page. Ask forpage1, 2, 3 until you havetotal.countentries. orderBytakes field names; a leading-sorts descending (["-date"]).- Date filters take comparisons as text:
">= 2026-10-01","<= 2026-10-31", or a single day. - Id filters take lists; most have a
…Nottwin that excludes.
Time entries of a month
query Time($project: [ID!], $dates: [String!]) {
total: timetrackings(filter: { projectIds: $project, dates: $dates }) {
count
minutes: sum(field: "duration")
}
timetrackings(filter: { projectIds: $project, dates: $dates }, orderBy: ["date"], perPage: 100, page: 1) {
edges {
node {
id
date
duration
description
user {
id
firstname
lastname
}
service {
id
title
}
ticket {
id
number
title
}
invoice {
id
number
}
}
}
}
}
With "dates": [">= 2026-10-01", "<= 2026-10-31"]. filter: { billing: [OPEN] } lists what no
invoice has billed yet; invoice names the invoice of an entry that is billed (for keys that may
see invoices).
Booking time
Time is booked on a ticket of a project, with a service (the kind of work) and a duration in minutes, in steps of 15.
mutation Book($input: CreateTimetrackingInput!) {
createTimetracking(input: $input) {
timetracking {
id
date
duration
}
errors
}
}
{
"input": {
"projectId": "…",
"ticketId": "…",
"serviceId": "…",
"date": "2026-11-12",
"duration": 90,
"description": "Homepage: hero and navigation"
}
}
Without userId the time is the key owner's. Find the ids first: projects, a project's
tickets, and services.
Creating a ticket
mutation NewTicket($input: CreateTicketInput!) {
createTicket(input: $input) {
ticket {
id
number
title
}
errors
}
}
With { "input": { "projectId": "…", "title": "Check the contact form", "text": "The form answers with an error." } }.
text is plain text; blank lines separate paragraphs. Comments take it too (createTicketComment).
Invoices
query Invoices($year: Int) {
invoices(filter: { year: $year, statuses: ["published", "paid"] }, orderBy: ["-date"], perPage: 50, page: 1) {
edges {
node {
id
number
title
status
date
dueDate
}
}
}
}
Only Project Managers and Admins read invoices. To hear about an invoice the moment it is issued or paid, use a webhook instead of asking again and again.
Files
Where the API names a file (an external cost's invoice { downloadUrl }, a ticket's
attachments { url }), it gives a path on your workspace. Fetch it with the same key:
GET https://yourworkspace.docket.rocks/rails/active_storage/blobs/redirect/…/receipt.pdf
Authorization: Bearer dkt_…
The answer redirects to the file, so let your tool follow redirects. A key gets the files its owner may open in the app: a receipt only with a Project Manager's or an Admin's key. Without a key, or with one that may not, the answer is 404. File requests count towards the key's 120 a minute.
When something goes wrong
| What you get | What it means |
|---|---|
| HTTP 401 | The key is missing, wrong or deleted, or the API is switched off for the workspace. |
| HTTP 429 | More than 120 requests in a minute with this key. Wait and try again. |
HTTP 200 with errors at the top of the answer |
The query doesn't fit the schema, or the key's owner may not do this. |
HTTP 200 with errors inside the mutation's answer |
The record wasn't saved. errors names each field with its messages, as the form in the app would show them. |
A mutation that worked answers with errors: null. Always ask for errors and check it.
What a key can't do
Workspace settings, users and their roles, connections to Jira and AI apps, and API keys and webhooks themselves are changed in the app only. Those mutations say so in the schema.
Related: API and webhooks · User roles