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:

FieldTypeDescription
idID(nullable)Stored appointment id. Null for a generated occurrence.
recurringRuleIdID(nullable)Series the occurrence came from
recurringDateDateTime(nullable)Slot the occurrence fills in that series
versionInt(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:

FieldTypeDescription
appointmentIdID(nullable)Stored appointment id
recurringRuleIdID(nullable)Series id — required together with recurringDate when there is no stored id
recurringDateDateTime(nullable)Slot in the series. Must be minute-aligned and land on a real occurrence.
driftVersionInt(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.

FieldTypeDescription
THISScopeOnly this occurrence. Materializes it as its own row, detached from the series, so later rule edits leave it alone.
THIS_AND_FOLLOWINGScopeSplits the series at this occurrence: the original rule is bounded just before it and a new rule carries the change forward.
ALLScopeRewrites 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:

FieldTypeDescription
FREQSupportedDAILY, WEEKLY, MONTHLY, YEARLY. Sub-daily frequencies are rejected.
INTERVALSupportedAny positive integer. 0 and non-integers are rejected.
BYDAYPartialPlain weekdays for DAILY, WEEKLY, and MONTHLY with BYSETPOS. Nth-weekday forms such as 2FR are rejected — use BYSETPOS instead.
BYMONTHDAYPartial1..31 for MONTHLY and YEARLY. Negative and last-day forms are rejected.
BYMONTHPartial1..12 for YEARLY. Defaults to the month of DTSTART.
BYSETPOSPartialA single value, MONTHLY only, and only alongside exactly one BYDAY weekday.
UNTIL / COUNTSupportedBoth resolve to an end date on the stored series.
WKSTPartialNon-Monday week starts are rejected for WEEKLY rules with INTERVAL greater than 1.
BYHOUR / BYMINUTE / BYSECONDPartialAccepted only when they restate the time-of-day already in DTSTART.
BYYEARDAY / BYWEEKNORejectedNot expressible in the occurrence generator.

Changing recurrence on an update has three additional rules:

  • Adding recurrence to a one-off appointment converts it into a series starting at that appointment.
  • Setting recurrence to null collapses a series back to a single appointment, and requires scope: ALL.
  • Changing the RRULE of an existing series requires scope: ALL or THIS_AND_FOLLOWING; THIS is 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:

FieldTypeDescription
startDate / endDateDateTime(nullable)Range to read. Required together, and required whenever filters are used.
filtersAppointmentV2Filters(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
includePinnedBoolean(nullable)Also return pinned appointments that fall outside the date range
searchString(nullable)Free-text match on customer name, street address, city and zip. Max 600 characters.
searchNotesString(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:

FieldTypeDescription
appointmentV2Permissionread:own:appointment or read:all:appointment
infiniteAppointmentsV2Permissionread:own:appointment or read:all:appointment
appointmentsV2CountsPermissionread:own:appointment or read:all:appointment
createAppointmentV2Permissionwrite:appointment
updateAppointmentV2Permissionwrite:appointment
updateAppointmentsByDateRangeV2Permissionwrite:appointment
routeOptimizationPermissionwrite:appointment
attachAppointmentMediaV2Permissionwrite:appointment
detachAppointmentMediaV2Permissionwrite:appointment
deleteAppointmentV2Permissiondelete:appointment
restoreAppointmentV2Permissionupdate:organization

Field Reference

Fields on AppointmentV2:

FieldTypeDescription
idID(nullable)Identifier of the stored row. Null for a generated occurrence that has never been edited.
identifierAppointmentV2Identifier(nullable)Addressable handle for this appointment — always request it if you intend to write back
dateDateTime!When the visit happens
recurringDateDateTime(nullable)Slot this occurrence fills in its series. Null for a one-off appointment.
recurringRuleIdID(nullable)Series this appointment belongs to
recurringRuleAppointmentRecurringRuleRoot(nullable)The series definition, including its RRULE string
recurringNextBoolean!Whether this appointment belongs to a recurring series
detachedBoolean(nullable)True once an occurrence has been edited on its own and stops tracking series changes. Null for one-off appointments.
durationInt!Duration in minutes
statusAppointmentV2StatusEnum(nullable)OPEN, ASSIGNED, or COMPLETE
priorityAppointmentV2PriorityEnum(nullable)LOW, MEDIUM, HIGH, or SPECIAL
colorString!Color code for calendar display
notesString(nullable)Notes visible to the customer
privateNotesString(nullable)Internal notes not visible to the customer
customDescriptionString(nullable)Overrides the service type name on customer-facing output
servicePriceInt(nullable)Price of the service in cents
serviceQuantityFloat(nullable)Quantity of the service
pinnedBoolean!Whether the appointment is pinned to its slot and excluded from route reordering
customerCustomer(nullable)Associated customer
workers[AppointmentWorker!]!Technicians assigned to this appointment
primaryWorkerAppointmentWorker(nullable)Primary technician
serviceTypeType(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
billingStatusBillingStatus(nullable)Billing status of the appointment
serviceStatusServiceStatus(nullable)Service status of the appointment. Always null for a generated occurrence.
projectProject(nullable)Project this appointment belongs to
appointmentQueueAppointmentQueue(nullable)Queue the appointment is filed under
appointmentQueueIdID(nullable)Queue id, without resolving the queue
emailedAtDateTime(nullable)When a confirmation email was last sent
textedAtDateTime(nullable)When a confirmation text was last sent
notifiedAtDateTime(nullable)When the customer was last notified
notifiedForDateTime(nullable)Visit date the last notification referred to
createdAtDateTime!Creation timestamp
updatedAtDateTime!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 fieldUse instead
appointmentappointmentV2
paginatedAppointmentsinfiniteAppointmentsV2
infiniteAppointmentsinfiniteAppointmentsV2
createAppointmentcreateAppointmentV2
updateAppointmentupdateAppointmentV2
deleteAppointmentdeleteAppointmentV2
restoreDeletedAppointmentrestoreAppointmentV2
AppointmentIdentifierInputAppointmentV2IdentifierInput
AppointmentsSelectorAppointmentsV2Selector
AppointmentFiltersAppointmentV2Filters
AppointmentSortAppointmentV2Sort
AppointmentStatusEnumAppointmentV2StatusEnum
AppointmentPriorityEnumAppointmentV2PriorityEnum
createAppointmentRecurringRulecreateAppointmentV2 with recurrence
forkAppointmentRecurringRuleupdateAppointmentV2 with scope THIS_AND_FOLLOWING
stopAppointmentRecurringRuleupdateAppointmentV2 with a bounded RRULE
convertAppointmentToAppointmentRecurringRuleupdateAppointmentV2 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.