Appearance
Core patterns
These patterns are the same for every domain in the API. Once you know them for jobs, you know them for orders, components, devices and the rest.
IDs
Every record has a typed ID made of a short prefix and a sortable unique suffix:
| Type | Prefix | Example |
|---|---|---|
| Job | job_ | job_01M0R249JMETNBM1Z7466MXGZD |
| Component | cmp_ | cmp_01M0R249NQEQGR8E91ZHP09KP0 |
| Operation | opr_ | |
| Milestone | mls_ | |
| Customer | cus_ | cus_01M0R2496SFANSZFKWPYW17V0J |
| Order | ord_ | |
| Order line | oln_ | |
| Estimate | est_ | |
| Invoice | inv_ | |
| Shipment | shp_ | |
| File | fil_ | |
| Device | dev_ |
Arguments are typed too: getJob(id:) takes a JobID!, and a value that is not a valid job ID (a malformed ID, or an ID with another prefix such as cus_) is rejected before the query runs. Treat IDs as opaque strings and store them as they are.
To find a record by the ID it has in your own system, set externalId when you create it and look it up with get<Entity>By(externalId:):
graphql
query JobByExternalId {
getJobBy(externalId: "MIS-2026-0042") {
id
name
status
}
}get<Entity> and get<Entity>By return null when nothing matches; they do not raise an error.
Pagination
List queries return a Relay-style connection:
graphql
query FirstPage {
listJobs(first: 25) {
totalCount
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}firstsets the page size. The default is 25; a value above 100 is capped at 100.- To get the next page, pass the previous page's
pageInfo.endCursorasafter. Stop whenhasNextPageisfalse. lastandbeforepage backwards on the queries that accept them (for examplelistJobs,listComponents,listCustomers). Some lists, such aslistOrders, take onlyfirstandafter; the API reference shows the arguments of each query.- A cursor encodes the position under the current sort order. Keep the same
filterandsortwhile you page through a result.
graphql
query NextPage($after: String) {
listJobs(first: 25, after: $after, sort: [{ field: CREATED_AT, direction: DESC }]) {
edges {
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}Relations on a single record (JobState.components, OrderState.orderLines) are plain lists, not connections, and take no arguments.
Filtering
Most list queries take a filter input with one entry per filterable field. Each entry is an operator object. The operators depend on the field type:
| Filter type | Operators |
|---|---|
| StringFilter | eq, neq, contains, startsWith, endsWith, in, notIn |
| IntFilter | eq, neq, gt, gte, lt, lte, in, notIn |
| DateFilter | eq, neq, gt, gte, lt, lte |
| IDFilter | eq, neq, in, notIn |
contains, startsWith and endsWith ignore case. Dates are ISO 8601 strings. When you set several fields, a record must match all of them.
graphql
query ActiveJobsDueThisWeek {
listJobs(
first: 50
filter: {
status: { in: ["PREPRESS", "PRESS", "POSTPRESS"] }
dueAt: { gte: "2026-09-21T00:00:00Z", lt: "2026-09-28T00:00:00Z" }
name: { contains: "brochure" }
}
) {
totalCount
edges {
node {
id
name
status
dueAt
}
}
}
}Points to watch:
- Status and kind filters are strings. Fields like
JobFilterInput.statusareStringFilters, so you writestatus: { eq: "PRESS" }with the value in quotes, even thoughJobState.statusitself is an enum. - ID filters always take an operator object. Write
billingCustomerId: { eq: "cus_…" }, neverbillingCustomerId: "cus_…". - Only the fields listed on the filter input exist. There are no nested or relationship filters (you cannot filter jobs by a property of their customer) and no
and/or/not. Filter by the related record's ID instead, or make two queries. Check the filter input in the reference, for example JobFilterInput.
graphql
query JobsForCustomer {
listJobs(filter: { billingCustomerId: { eq: "cus_01M0R2496SFANSZFKWPYW17V0J" } }) {
edges {
node {
id
name
}
}
}
}Counting and aggregates
Jobs, components, operations and metric points have an aggregate query (aggregateJobs, aggregateComponents, …) that takes the same filter and returns counts and numeric summaries without fetching records:
graphql
query OpenJobCount {
aggregateJobs(filter: { status: { notIn: ["COMPLETED", "CANCELLED"] } }) {
count
}
}Sorting
sort is a list of { field, direction } objects, applied in order. direction is ASC or DESC (SortDirection). The sortable fields are an enum per entity, for example JobSortField:
graphql
query JobsByPriority {
listJobs(
first: 20
sort: [{ field: PRIORITY, direction: DESC }, { field: DUE_AT, direction: ASC }]
) {
edges {
node {
id
name
priority
dueAt
}
}
}
}Enums
Enum values are upper case on the wire: CONFIRMED, DIGITAL_PRINTING, MM. Use them bare in queries and as JSON strings in variables.
The API reference lists the values of each enum, for example JobStatus. GET /graphql/schema on your tenant host returns the values of the release your tenant runs.
Dates and amounts
Timestamps use the ISO8601 scalar. Any valid ISO 8601 offset is accepted on input (2026-10-15T18:00:00+02:00); responses are always in UTC with a Z suffix. Money amounts are plain numbers, next to a currency field holding a three-letter ISO 4217 code such as "EUR".
Mutations
Most mutations follow one naming scheme (the API reference shows the exceptions):
| Mutation | Arguments | Behaviour |
|---|---|---|
create<Entity> | input | Creates a record. |
update<Entity> | id, input | Changes the fields you pass and leaves the rest as they are. |
upsert<Entity> | input | Updates the record whose id is in the input, or creates one when there is no id. |
delete<Entity> | varies per entity | Deletes the record. Most return { success errors }. |
Create, update and upsert return a payload with the record under its own name (job, component, order, …) and an errors list:
graphql
mutation CreateJob {
createJob(input: { name: "Spring catalogue", quantity: 5000, externalId: "MIS-2026-0042" }) {
job {
id
status
}
errors {
path
message
}
}
}Always select errors. If the input fails validation, the record is null and errors explains why:
json
{
"data": {
"createJob": {
"job": null,
"errors": [{ "path": "name", "message": "cannot be empty" }]
}
}
}path names the input field that failed (a dotted path for nested input), and message is a human-readable reason. errors is an empty list on success.
Use variables rather than building query strings:
graphql
mutation UpdateJob($id: JobID!, $input: UpdateJobInput!) {
updateJob(id: $id, input: $input) {
job {
id
status
dueAt
}
errors {
path
message
}
}
}json
{
"id": "job_01M0R249JMETNBM1Z7466MXGZD",
"input": { "status": "PREPRESS", "dueAt": "2026-10-01T12:00:00Z" }
}Errors
There are two places an error can appear:
| Where | What it means | Examples |
|---|---|---|
errors in a mutation payload | The request ran, but the input was rejected. | A required field is blank, a value is out of range. |
Top-level errors in the GraphQL response | The operation did not run as asked. | Invalid query or argument (such as a malformed ID), Authentication required, Access denied: not authorized to perform '<action>' on '<resource>'. |
A missing record is neither: get<Entity> returns null.
Handle both in client code:
js
const res = await fetch(`https://${host}/graphql`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
})
const body = await res.json()
if (body.errors?.length) throw new Error(body.errors.map(e => e.message).join('; '))
const { job, errors } = body.data.createJob
if (errors.length) {
// show errors[i].path / errors[i].message to the user
}Fragments
Fragments name a selection so you can reuse it:
graphql
fragment JobSummary on JobState {
id
jobNumber
name
status
dueAt
}
query Dashboard {
urgent: listJobs(first: 10, sort: [{ field: PRIORITY, direction: DESC }]) {
edges {
node {
...JobSummary
}
}
}
dueSoon: listJobs(first: 10, sort: [{ field: DUE_AT, direction: ASC }]) {
edges {
node {
...JobSummary
}
}
}
}Good habits
- Select only the fields you use, and keep nesting shallow. Every nested list is fetched in full.
- Page with
first/afterinstead of pulling large lists in one request. - Set
externalIdon records you create from another system. Before creating, look the record up withget<Entity>By(externalId:)and update it if it exists, so a retried sync does not create duplicates. - Use IDs as cache keys; they never change.