Appointments
Manage scheduled service appointments, one-off and recurring
Overview
Appointments represent scheduled service visits. They carry a customer, a service type, assigned technicians, inventory used, and notes, and they move through three statuses: OPEN, ASSIGNED, and COMPLETE.
A recurring appointment is stored once, as a recurring rule holding an RRULE. Reads expand that rule into individual occurrences on the fly for the date range you ask for, so a weekly series covering five years costs one row rather than 260. Writes then decide how much of the series they touch.
Stored rows and generated occurrences
Every appointment you read back is one of two things. A stored appointment has its own row and a non-null id — a one-off visit, or an occurrence that was edited individually. A generated occurrence was expanded from a rule and has never been touched, so its id is null and it is addressed by recurringRuleId plus recurringDate.
Because id can be null, never key your integration on it alone. Request the identifier object and use that as the handle — it addresses both kinds uniformly.
Identifiers
Reads return an AppointmentV2Identifier:
| Field | Type | Description |
|---|---|---|
id | ID(nullable) | Stored appointment id. Null for a generated occurrence. |
recurringRuleId | ID(nullable) | Series the occurrence came from |
recurringDate | DateTime(nullable) | Slot the occurrence fills in that series |
version | Int(nullable) | Revision of the series this occurrence was generated from. Pass it back as driftVersion when writing. |
Two input shapes consume it. List and lookup arguments take an AppointmentV2ListIdentifierInput — either id, or recurringRuleId together with recurringDate. Supplying one half of that pair without the other is a validation error.
Mutations take an AppointmentV2IdentifierInput, which names the stored id appointmentId rather than id and adds an optional driftVersion:
| Field | Type | Description |
|---|---|---|
appointmentId | ID(nullable) | Stored appointment id |
recurringRuleId | ID(nullable) | Series id — required together with recurringDate when there is no stored id |
recurringDate | DateTime(nullable) | Slot in the series. Must be minute-aligned and land on a real occurrence. |
driftVersion | Int(nullable) | Series revision the occurrence was read from — pass back identifier.version. Omit to resolve against the current revision. |
So the round trip from a read to a write remaps two field names:
# read
identifier { id recurringDate recurringRuleId version }
# write
identifier: {
appointmentId: <id>,
recurringRuleId: <recurringRuleId>,
recurringDate: <recurringDate>,
driftVersion: <version>
}Editing a series can retire the revision an occurrence came from. If you cached identifiers, pass driftVersion so the write resolves against the revision you actually saw; the API follows the series forward from there rather than failing on a date that has since moved.
Write Scope
updateAppointmentV2 and deleteAppointmentV2 require a scope (AppointmentWriteScopeEnum) saying how much of the series the write applies to. For a one-off appointment every scope behaves the same.
| Field | Type | Description |
|---|---|---|
THIS | Scope | Only this occurrence. Materializes it as its own row, detached from the series, so later rule edits leave it alone. |
THIS_AND_FOLLOWING | Scope | Splits the series at this occurrence: the original rule is bounded just before it and a new rule carries the change forward. |
ALL | Scope | Rewrites the whole series in place, including occurrences already in the past. |
Recurrence
Recurrence is set through the recurrence field on create and update, an AppointmentV2RecurrenceInput with two required members: an rrule string (RFC 5545, optionally including a DTSTART line with a TZID) and the IANA timeZone the series is anchored in. The time zone matters: it is what keeps a 9am visit at 9am local across daylight-saving changes.
{
"recurrence": {
"rrule": "DTSTART;TZID=America/Phoenix:20260401T090000\nRRULE:FREQ=WEEKLY;BYDAY=TU;INTERVAL=1",
"timeZone": "America/Phoenix"
}
}Occurrences are expanded in the database, which supports a deliberate subset of RRULE. Anything outside it is rejected at write time with a validation error naming the offending part, rather than being silently mis-expanded:
| Field | Type | Description |
|---|---|---|
FREQ | Supported | DAILY, WEEKLY, MONTHLY, YEARLY. Sub-daily frequencies are rejected. |
INTERVAL | Supported | Any positive integer. 0 and non-integers are rejected. |
BYDAY | Partial | Plain weekdays for DAILY, WEEKLY, and MONTHLY with BYSETPOS. Nth-weekday forms such as 2FR are rejected — use BYSETPOS instead. |
BYMONTHDAY | Partial | 1..31 for MONTHLY and YEARLY. Negative and last-day forms are rejected. |
BYMONTH | Partial | 1..12 for YEARLY. Defaults to the month of DTSTART. |
BYSETPOS | Partial | A single value, MONTHLY only, and only alongside exactly one BYDAY weekday. |
UNTIL / COUNT | Supported | Both resolve to an end date on the stored series. |
WKST | Partial | Non-Monday week starts are rejected for WEEKLY rules with INTERVAL greater than 1. |
BYHOUR / BYMINUTE / BYSECOND | Partial | Accepted only when they restate the time-of-day already in DTSTART. |
BYYEARDAY / BYWEEKNO | Rejected | Not expressible in the occurrence generator. |
Changing recurrence on an update has three additional rules:
- Adding
recurrenceto a one-off appointment converts it into a series starting at that appointment. - Setting
recurrencetonullcollapses a series back to a single appointment, and requiresscope: ALL. - Changing the RRULE of an existing series requires
scope: ALLorTHIS_AND_FOLLOWING;THISis rejected, as is any recurrence change targeting an already-detached occurrence.
List Appointments
infiniteAppointmentsV2 returns a cursor-paginated list with generated occurrences already expanded and merged in. It takes an AppointmentsV2Selector and a sort array, and paginates forward (first/after) or backward (last/before). Page size defaults to 200 and is capped at 500.
startDate and endDate must be supplied together, with endDate after startDate, and they are required whenever you pass filters — the range is what bounds occurrence generation. Request pageInfo.totalEdges only when you need a total; it costs a second count query.
query InfiniteAppointmentsV2(
$first: Int!,
$after: String,
$selector: AppointmentsV2Selector,
$sort: [AppointmentV2Sort!]
) {
infiniteAppointmentsV2(
first: $first
after: $after
selector: $selector
sort: $sort
) {
edges {
cursor
node {
id
identifier {
id
recurringDate
recurringRuleId
version
}
date
duration
status
priority
color
notes
pinned
detached
recurringRuleId
customer {
id
firstName
lastName
}
workers {
id
firstName
lastName
primary
}
primaryWorker {
id
firstName
lastName
}
serviceType {
id
display
}
billingStatus {
id
name
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
# Variables
{
"first": 50,
"after": null,
"selector": {
"startDate": "2026-04-01T00:00:00.000Z",
"endDate": "2026-04-30T23:59:59.999Z",
"filters": {
"status": { "in": ["OPEN", "ASSIGNED"] }
},
"includePinned": true
},
"sort": [{ "field": "date", "order": "ASC" }]
}Response:
{
"data": {
"infiniteAppointmentsV2": {
"edges": [
{
"cursor": "eyJ0IjoiYXBwb2ludG1lbnRWMiIsInYiOlsiMjAyNi0wNC0wNyJdfQ",
"node": {
"id": null,
"identifier": {
"id": null,
"recurringDate": "2026-04-07T16:00:00.000Z",
"recurringRuleId": "clxyz_rule_001",
"version": 3
},
"date": "2026-04-07T16:00:00.000Z",
"duration": 60,
"status": "ASSIGNED",
"priority": "MEDIUM",
"color": "#4A90D9",
"notes": null,
"pinned": false,
"detached": false,
"recurringRuleId": "clxyz_rule_001",
"customer": {
"id": "clxyz_cust_001",
"firstName": "John",
"lastName": "Smith"
},
"workers": [
{
"id": "clxyz_worker_001",
"firstName": "Mike",
"lastName": "Johnson",
"primary": true
}
],
"primaryWorker": {
"id": "clxyz_worker_001",
"firstName": "Mike",
"lastName": "Johnson"
},
"serviceType": {
"id": "clxyz_svc_001",
"display": "Weekly Cleaning"
},
"billingStatus": {
"id": "clxyz_bs_001",
"name": "Unbilled"
}
}
}
],
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "eyJ0IjoiYXBwb2ludG1lbnRWMiIsInYiOlsiMjAyNi0wNC0wNyJdfQ",
"endCursor": "eyJ0IjoiYXBwb2ludG1lbnRWMiIsInYiOlsiMjAyNi0wNC0wNyJdfQ"
}
}
}
}Filtering and Sorting
AppointmentsV2Selector accepts:
| Field | Type | Description |
|---|---|---|
startDate / endDate | DateTime(nullable) | Range to read. Required together, and required whenever filters are used. |
filters | AppointmentV2Filters(nullable) | Field-level filters, listed below |
include | [AppointmentV2ListIdentifierInput!](nullable) | Extra appointments to add to the result even if they fall outside the range or filters |
exclude | [AppointmentV2ListIdentifierInput!](nullable) | Appointments to drop from the result |
includePinned | Boolean(nullable) | Also return pinned appointments that fall outside the date range |
search | String(nullable) | Free-text match on customer name, street address, city and zip. Max 600 characters. |
searchNotes | String(nullable) | Free-text match on notes and private notes. Max 600 characters. |
AppointmentV2Filters covers status, priority, customer, customerTags, serviceType, workers, primaryWorker, billingStatus, serviceStatus, recurringRule, duration, servicePrice, color, notes, privateNotes, pinned, emailedAt, textedAt, and notifiedAt. Each takes the usual scalar filter object — equals, not, in, notIn, plus contains/startsWith/endsWith on text and greaterThan/lessThan variants on numbers and dates. workers takes some/every/none array filters, and recurringRule nests filters on the series itself, including rruleString.
{
"selector": {
"startDate": "2026-04-01T00:00:00.000Z",
"endDate": "2026-04-30T23:59:59.999Z",
"filters": {
"status": { "not": "COMPLETE" },
"priority": { "in": ["HIGH", "SPECIAL"] },
"workers": { "id": { "some": ["clxyz_worker_001"] } },
"serviceType": { "equals": "clxyz_svc_001" },
"notes": { "contains": "gate code" }
},
"searchNotes": "back gate"
}
}Sorting takes an array of AppointmentV2Sort entries, each a field and an order of ASC or DESC. Relation fields are addressed with a triple underscore. Available fields: date, duration, status, priority, color, notes, privateNotes, notifiedAt, createdAt, updatedAt, customer___firstName, customer___lastName, customer___streetAddress, customer___city, customer___state, customer___zipCode, customer___phoneNumber, customer___id, primaryWorker___firstName, primaryWorker___lastName, primaryWorker___id, serviceType___name, serviceType___id, billingStatus___name, billingStatus___id, serviceStatus___name, and serviceStatus___id.
Calendar Counts
appointmentsV2Counts tallies a selector without paging through it — useful for calendar badges. It returns organization totals plus a per-day breakdown, split by whether a technician is assigned. Days with no matching appointment are omitted. Pass the timeZone your calendar grid renders in so the day buckets line up; it defaults to the organization time zone, then UTC.
query AppointmentsV2Counts(
$selector: AppointmentsV2Selector!,
$timeZone: TimeZoneEnum
) {
appointmentsV2Counts(selector: $selector, timeZone: $timeZone) {
total
assigned
unassigned
timeZone
days {
date
total
assigned
unassigned
}
}
}
# Variables
{
"selector": {
"startDate": "2026-04-01T00:00:00.000Z",
"endDate": "2026-04-07T23:59:59.999Z"
},
"timeZone": "AMERICA___PHOENIX"
}Response:
{
"data": {
"appointmentsV2Counts": {
"total": 34,
"assigned": 29,
"unassigned": 5,
"timeZone": "America/Phoenix",
"days": [
{ "date": "2026-04-01", "total": 6, "assigned": 6, "unassigned": 0 },
{ "date": "2026-04-02", "total": 5, "assigned": 4, "unassigned": 1 },
{ "date": "2026-04-03", "total": 7, "assigned": 6, "unassigned": 1 }
]
}
}
}Get Single Appointment
appointmentV2 resolves one appointment from an AppointmentV2ListIdentifierInput — a stored id, or a recurringRuleId and recurringDate pair, which generates the occurrence on demand even though no row exists for it. Returns null if nothing matches.
query AppointmentV2($identifier: AppointmentV2ListIdentifierInput!) {
appointmentV2(identifier: $identifier) {
id
identifier {
id
recurringDate
recurringRuleId
version
}
date
duration
status
priority
color
notes
privateNotes
customDescription
servicePrice
serviceQuantity
pinned
detached
recurringNext
recurringDate
recurringRuleId
recurringRule {
id
rruleString
startDate
endDate
duration
}
customer {
id
firstName
lastName
email
phoneNumber
}
workers {
id
firstName
lastName
primary
}
primaryWorker {
id
firstName
lastName
}
serviceType {
id
display
}
inventoryItems {
inventoryId
name
quantity
price
}
media {
id
fileId
mediaType
isPrivate
isPinned
}
billingStatus {
id
name
}
serviceStatus {
id
name
}
project {
id
name
}
createdAt
updatedAt
}
}
# Variables
{
"identifier": {
"recurringRuleId": "clxyz_rule_001",
"recurringDate": "2026-04-07T16:00:00.000Z"
}
}Response:
{
"data": {
"appointmentV2": {
"id": null,
"identifier": {
"id": null,
"recurringDate": "2026-04-07T16:00:00.000Z",
"recurringRuleId": "clxyz_rule_001",
"version": 3
},
"date": "2026-04-07T16:00:00.000Z",
"duration": 60,
"status": "ASSIGNED",
"priority": "MEDIUM",
"color": "#4A90D9",
"notes": "Check chlorine levels",
"privateNotes": "Customer prefers back gate entry",
"customDescription": null,
"servicePrice": 7500,
"serviceQuantity": 1.0,
"pinned": false,
"detached": false,
"recurringNext": true,
"recurringDate": "2026-04-07T16:00:00.000Z",
"recurringRuleId": "clxyz_rule_001",
"recurringRule": {
"id": "clxyz_rule_001",
"rruleString": "DTSTART;TZID=America/Phoenix:20260401T090000\nRRULE:FREQ=WEEKLY;BYDAY=TU",
"startDate": "2026-04-01T16:00:00.000Z",
"endDate": null,
"duration": 60
},
"customer": {
"id": "clxyz_cust_001",
"firstName": "John",
"lastName": "Smith",
"email": "john.smith@example.com",
"phoneNumber": "555-0123"
},
"workers": [
{
"id": "clxyz_worker_001",
"firstName": "Mike",
"lastName": "Johnson",
"primary": true
}
],
"primaryWorker": {
"id": "clxyz_worker_001",
"firstName": "Mike",
"lastName": "Johnson"
},
"serviceType": {
"id": "clxyz_svc_001",
"display": "Weekly Cleaning"
},
"inventoryItems": [
{
"inventoryId": "412",
"name": "Chlorine Tablets",
"quantity": 2.0,
"price": 12.0
}
],
"media": [],
"billingStatus": {
"id": "clxyz_bs_001",
"name": "Unbilled"
},
"serviceStatus": null,
"project": null,
"createdAt": "2026-03-01T10:00:00.000Z",
"updatedAt": "2026-03-10T14:30:00.000Z"
}
}
}Create Appointment
createAppointmentV2 takes a CreateAppointmentV2Input. Only customer, date, and duration are required — omit inventoryItems entirely when there is none. Pass recurrence to create a series instead of a single visit; the appointment at date becomes its first occurrence.
Assigning technicians: put user ids in workers and optionally name one of them in primaryWorker. Both take user ids, which you can look up with the Users API (for example infiniteUsers or lookupUser). Omit workers or pass [] for an unassigned appointment. Assigning at least one worker moves the appointment from OPEN to ASSIGNED, whatever status you send.
Inventory ids are numeric. AppointmentV2InventoryItemInput.inventoryId is an Int!, not a cuid. Set useDefaultPrice: true to bill at the item default instead of sending price.
mutation CreateAppointmentV2($data: CreateAppointmentV2Input!) {
createAppointmentV2(data: $data) {
id
identifier {
id
recurringDate
recurringRuleId
version
}
date
duration
status
priority
color
recurringRuleId
customer {
id
firstName
lastName
}
workers {
id
firstName
lastName
primary
}
serviceType {
id
display
}
inventoryItems {
inventoryId
name
quantity
price
}
}
}
# Variables — a single visit
{
"data": {
"customer": "clxyz_cust_001",
"date": "2026-04-01T17:00:00.000Z",
"duration": 90,
"serviceType": "clxyz_svc_001",
"workers": ["clxyz_worker_001"],
"primaryWorker": "clxyz_worker_001",
"inventoryItems": [
{ "inventoryId": 412, "quantity": 2.0, "price": 12.0 }
],
"notes": "Annual pool inspection",
"color": "#4A90D9",
"priority": "MEDIUM",
"servicePrice": 15000,
"serviceQuantity": 1.0
}
}
# Variables — a weekly series
{
"data": {
"customer": "clxyz_cust_001",
"date": "2026-04-01T16:00:00.000Z",
"duration": 60,
"serviceType": "clxyz_svc_001",
"workers": ["clxyz_worker_001"],
"primaryWorker": "clxyz_worker_001",
"recurrence": {
"rrule": "DTSTART;TZID=America/Phoenix:20260401T090000\nRRULE:FREQ=WEEKLY;BYDAY=WE",
"timeZone": "America/Phoenix"
}
}
}Response:
{
"data": {
"createAppointmentV2": {
"id": "clxyz_apt_new",
"identifier": {
"id": "clxyz_apt_new",
"recurringDate": null,
"recurringRuleId": null,
"version": null
},
"date": "2026-04-01T17:00:00.000Z",
"duration": 90,
"status": "ASSIGNED",
"priority": "MEDIUM",
"color": "#4A90D9",
"recurringRuleId": null,
"customer": {
"id": "clxyz_cust_001",
"firstName": "John",
"lastName": "Smith"
},
"workers": [
{
"id": "clxyz_worker_001",
"firstName": "Mike",
"lastName": "Johnson",
"primary": true
}
],
"serviceType": {
"id": "clxyz_svc_001",
"display": "Pool Inspection"
},
"inventoryItems": [
{
"inventoryId": "412",
"name": "Chlorine Tablets",
"quantity": 2.0,
"price": 12.0
}
]
}
}
}Update Appointment
updateAppointmentV2 takes an identifier, an UpdateAppointmentV2Input, and a required scope. Every field in the input is optional; omitted fields are left alone.
Clearing a relation: send null or an empty string for a relation id (serviceType, billingStatus, serviceStatus, project, appointmentQueue, primaryWorker) to unset it. Omitting the field leaves it as it was.
Reassigning technicians: workers replaces the whole crew rather than adding to it, so pass [] to unassign everyone. Send primaryWorker as one of the ids in that array to change the primary technician. Omitting both leaves the assignment untouched, so you can edit other fields without disturbing the crew.
Editing one occurrence of a series with scope: THIS writes a real row for that occurrence and marks it detached. It keeps its own values from then on and stops picking up later edits to the series.
mutation UpdateAppointmentV2(
$identifier: AppointmentV2IdentifierInput!,
$data: UpdateAppointmentV2Input!,
$scope: AppointmentWriteScopeEnum!
) {
updateAppointmentV2(identifier: $identifier, data: $data, scope: $scope) {
id
identifier {
id
recurringDate
recurringRuleId
version
}
date
duration
status
priority
notes
detached
workers {
id
firstName
lastName
primary
}
updatedAt
}
}
# Variables — complete one occurrence of a series
{
"identifier": {
"recurringRuleId": "clxyz_rule_001",
"recurringDate": "2026-04-07T16:00:00.000Z",
"driftVersion": 3
},
"scope": "THIS",
"data": {
"status": "COMPLETE",
"notes": "Service completed, chlorine levels adjusted",
"workers": ["clxyz_worker_002"],
"primaryWorker": "clxyz_worker_002"
}
}
# Variables — move the rest of the series to Thursdays
{
"identifier": {
"recurringRuleId": "clxyz_rule_001",
"recurringDate": "2026-04-07T16:00:00.000Z"
},
"scope": "THIS_AND_FOLLOWING",
"data": {
"recurrence": {
"rrule": "DTSTART;TZID=America/Phoenix:20260409T090000\nRRULE:FREQ=WEEKLY;BYDAY=TH",
"timeZone": "America/Phoenix"
}
}
}Response:
{
"data": {
"updateAppointmentV2": {
"id": "clxyz_apt_exception",
"identifier": {
"id": "clxyz_apt_exception",
"recurringDate": "2026-04-07T16:00:00.000Z",
"recurringRuleId": "clxyz_rule_001",
"version": null
},
"date": "2026-04-07T16:00:00.000Z",
"duration": 60,
"status": "COMPLETE",
"priority": "MEDIUM",
"notes": "Service completed, chlorine levels adjusted",
"detached": true,
"workers": [
{
"id": "clxyz_worker_002",
"firstName": "James",
"lastName": "Davis",
"primary": true
}
],
"updatedAt": "2026-04-07T17:15:00.000Z"
}
}
}Update a Range of a Series
To apply the same change to every occurrence of one series between two dates, use updateAppointmentsByDateRangeV2. It runs asynchronously and returns a BulkOperation to poll — see the Bulk Operations guide. Each occurrence in the range is written individually, as if you had called updateAppointmentV2 with scope: THIS on each.
mutation UpdateRange(
$recurringRuleId: String!,
$rangeStart: DateTime!,
$rangeEnd: DateTime!,
$data: UpdateAppointmentV2Input!
) {
updateAppointmentsByDateRangeV2(
recurringRuleId: $recurringRuleId
rangeStart: $rangeStart
rangeEnd: $rangeEnd
data: $data
) {
id
type
status
}
}
# Variables
{
"recurringRuleId": "clxyz_rule_001",
"rangeStart": "2026-06-01T00:00:00.000Z",
"rangeEnd": "2026-08-31T23:59:59.999Z",
"data": {
"workers": ["clxyz_worker_003"],
"primaryWorker": "clxyz_worker_003"
}
}Delete Appointment
deleteAppointmentV2 takes an identifier and a required scope, and returns a Status. With scope: THIS a single occurrence of a series is suppressed while the rest of the series stays intact; THIS_AND_FOLLOWING ends the series just before that occurrence; ALL removes the whole series.
mutation DeleteAppointmentV2(
$identifier: AppointmentV2IdentifierInput!,
$scope: AppointmentWriteScopeEnum!
) {
deleteAppointmentV2(identifier: $identifier, scope: $scope) {
status
}
}
# Variables
{
"identifier": {
"recurringRuleId": "clxyz_rule_001",
"recurringDate": "2026-04-07T16:00:00.000Z"
},
"scope": "THIS"
}Response:
{
"data": {
"deleteAppointmentV2": {
"status": "OK"
}
}
}Restore a Deleted Appointment
restoreAppointmentV2 undoes a delete, bringing back a stored appointment or lifting the suppression on a deleted occurrence of a series. It takes the same identifier shape as the other mutations and requires the update:organization scope, so it is an administrative action rather than part of the normal write path.
mutation RestoreAppointmentV2($identifier: AppointmentV2IdentifierInput!) {
restoreAppointmentV2(identifier: $identifier) {
id
date
status
detached
}
}
# Variables
{
"identifier": {
"recurringRuleId": "clxyz_rule_001",
"recurringDate": "2026-04-07T16:00:00.000Z"
}
}Photos and Files
Media is attached by reference: upload the file first, then link it to an appointment with attachAppointmentMediaV2. The scope here is an AppointmentMediaScopeEnum — THIS attaches to the single occurrence, SERIES attaches to the recurring rule so every occurrence shows it. Read it back through the media field on the appointment, and unlink with detachAppointmentMediaV2.
mutation AttachMedia(
$identifier: AppointmentV2IdentifierInput!,
$data: AttachAppointmentMediaInput!,
$scope: AppointmentMediaScopeEnum!
) {
attachAppointmentMediaV2(identifier: $identifier, data: $data, scope: $scope) {
id
fileId
mediaType
description
isPrivate
isPinned
file {
id
url
}
}
}
# Variables
{
"identifier": { "appointmentId": "clxyz1234567890" },
"scope": "THIS",
"data": {
"fileId": "clxyz_file_001",
"mediaType": "image",
"description": "Filter cartridge before cleaning",
"isPrivate": false,
"isPinned": true
}
}
mutation DetachMedia($id: ID!) {
detachAppointmentMediaV2(id: $id) {
status
}
}Route Optimization
routeOptimization reschedules a list of appointments to new times in one asynchronous job, returning a BulkOperation. Each stop names an appointment the same way a mutation identifier does, plus the date it should move to. scope defaults to THIS and also accepts THIS_AND_FOLLOWING; ALL is rejected.
mutation RouteOptimization(
$stops: [AppointmentV2RouteStopInput!]!,
$scope: AppointmentWriteScopeEnum
) {
routeOptimization(stops: $stops, scope: $scope) {
id
type
status
}
}
# Variables
{
"scope": "THIS",
"stops": [
{
"appointmentId": "clxyz1234567890",
"date": "2026-04-07T15:00:00.000Z"
},
{
"recurringRuleId": "clxyz_rule_001",
"recurringDate": "2026-04-07T16:00:00.000Z",
"driftVersion": 3,
"date": "2026-04-07T17:30:00.000Z"
}
]
}Bulk & Export Operations
Creating, updating, deleting, emailing, texting, or printing many appointments at once is handled asynchronously through the Bulk Operations API — bulkCreateAppointmentsV2, bulkUpdateAppointmentsV2, bulkDeleteAppointmentsV2, bulkEmailAppointmentsV2, bulkTextAppointmentsV2, and bulkPrintAppointmentsV2. These jobs run in the background and report progress you can poll. See the Bulk Operations guide for the full workflow.
Note that the bulk mutations target records with the older AppointmentsSelector rather than AppointmentsV2Selector, and take an action of UPDATE_CURRENT or UPDATE_CURRENT_AND_FUTURE in place of a write scope.
Authorization
Appointment operations require the following permissions:
| Field | Type | Description |
|---|---|---|
appointmentV2 | Permission | read:own:appointment or read:all:appointment |
infiniteAppointmentsV2 | Permission | read:own:appointment or read:all:appointment |
appointmentsV2Counts | Permission | read:own:appointment or read:all:appointment |
createAppointmentV2 | Permission | write:appointment |
updateAppointmentV2 | Permission | write:appointment |
updateAppointmentsByDateRangeV2 | Permission | write:appointment |
routeOptimization | Permission | write:appointment |
attachAppointmentMediaV2 | Permission | write:appointment |
detachAppointmentMediaV2 | Permission | write:appointment |
deleteAppointmentV2 | Permission | delete:appointment |
restoreAppointmentV2 | Permission | update:organization |
Field Reference
Fields on AppointmentV2:
| Field | Type | Description |
|---|---|---|
id | ID(nullable) | Identifier of the stored row. Null for a generated occurrence that has never been edited. |
identifier | AppointmentV2Identifier(nullable) | Addressable handle for this appointment — always request it if you intend to write back |
date | DateTime! | When the visit happens |
recurringDate | DateTime(nullable) | Slot this occurrence fills in its series. Null for a one-off appointment. |
recurringRuleId | ID(nullable) | Series this appointment belongs to |
recurringRule | AppointmentRecurringRuleRoot(nullable) | The series definition, including its RRULE string |
recurringNext | Boolean! | Whether this appointment belongs to a recurring series |
detached | Boolean(nullable) | True once an occurrence has been edited on its own and stops tracking series changes. Null for one-off appointments. |
duration | Int! | Duration in minutes |
status | AppointmentV2StatusEnum(nullable) | OPEN, ASSIGNED, or COMPLETE |
priority | AppointmentV2PriorityEnum(nullable) | LOW, MEDIUM, HIGH, or SPECIAL |
color | String! | Color code for calendar display |
notes | String(nullable) | Notes visible to the customer |
privateNotes | String(nullable) | Internal notes not visible to the customer |
customDescription | String(nullable) | Overrides the service type name on customer-facing output |
servicePrice | Int(nullable) | Price of the service in cents |
serviceQuantity | Float(nullable) | Quantity of the service |
pinned | Boolean! | Whether the appointment is pinned to its slot and excluded from route reordering |
customer | Customer(nullable) | Associated customer |
workers | [AppointmentWorker!]! | Technicians assigned to this appointment |
primaryWorker | AppointmentWorker(nullable) | Primary technician |
serviceType | Type(nullable) | Type of service being performed |
inventoryItems | [AppointmentInventoryItem!](nullable) | Inventory items used on this visit |
media | [AppointmentMedia!]! | Photos and files attached to this appointment or its series |
billingStatus | BillingStatus(nullable) | Billing status of the appointment |
serviceStatus | ServiceStatus(nullable) | Service status of the appointment. Always null for a generated occurrence. |
project | Project(nullable) | Project this appointment belongs to |
appointmentQueue | AppointmentQueue(nullable) | Queue the appointment is filed under |
appointmentQueueId | ID(nullable) | Queue id, without resolving the queue |
emailedAt | DateTime(nullable) | When a confirmation email was last sent |
textedAt | DateTime(nullable) | When a confirmation text was last sent |
notifiedAt | DateTime(nullable) | When the customer was last notified |
notifiedFor | DateTime(nullable) | Visit date the last notification referred to |
createdAt | DateTime! | Creation timestamp |
updatedAt | DateTime! | Last update timestamp |
Coming From the Older Endpoints
An earlier generation of appointment fields — appointment, paginatedAppointments, createAppointment and the separate appointmentRecurringRule mutations — is still present in the schema but superseded by the fields on this page. New integrations should use the V2 fields; existing ones map across as follows.
| Older field | Use instead |
|---|---|
appointment | appointmentV2 |
paginatedAppointments | infiniteAppointmentsV2 |
infiniteAppointments | infiniteAppointmentsV2 |
createAppointment | createAppointmentV2 |
updateAppointment | updateAppointmentV2 |
deleteAppointment | deleteAppointmentV2 |
restoreDeletedAppointment | restoreAppointmentV2 |
AppointmentIdentifierInput | AppointmentV2IdentifierInput |
AppointmentsSelector | AppointmentsV2Selector |
AppointmentFilters | AppointmentV2Filters |
AppointmentSort | AppointmentV2Sort |
AppointmentStatusEnum | AppointmentV2StatusEnum |
AppointmentPriorityEnum | AppointmentV2PriorityEnum |
createAppointmentRecurringRule | createAppointmentV2 with recurrence |
forkAppointmentRecurringRule | updateAppointmentV2 with scope THIS_AND_FOLLOWING |
stopAppointmentRecurringRule | updateAppointmentV2 with a bounded RRULE |
convertAppointmentToAppointmentRecurringRule | updateAppointmentV2 adding recurrence |
Three differences bite hardest when porting. Recurrence is no longer a set of dedicated mutations — it is the recurrence field plus a write scope. The id on a read is nullable, so the identifier object is the handle to keep. And inventoryId is an Int, where the older input took a string.