Bulk Operations

Run create, update, delete, export, and messaging actions across many records asynchronously

Overview

Actions that affect many records at once — creating invoices for a whole route, exporting customers to a spreadsheet, emailing every customer matching a filter, deleting a batch of appointments — are handled by the Bulk Operations API rather than by per-resource REST endpoints.

Each request enqueues a background job and returns a BulkOperation immediately. A worker processes the records one by one, recording a result for each, while you poll the operation for live progress. This keeps large jobs off the request/response path and gives you per-record success, failure, and skip reporting.

The lifecycle

  1. Call an enqueue mutation (e.g. bulkExportCustomers) with the records or a selector. It returns a BulkOperation with status PENDING.
  2. The worker picks it up and moves it to PROCESSING, creating a BulkOperationItem per record.
  3. Poll bulkOperation(id) for status and progress, and bulkOperationItems for per-record outcomes.
  4. The operation settles on a terminal status — COMPLETED, COMPLETED_WITH_ERRORS, FAILED, or CANCELLED. For exports, a downloadUrl becomes available.

Operation Types

Every operation has a type (BulkOperationType). You can filter the operations list by type. The available types, grouped by resource:

ResourceOperation types
AppointmentsAPPOINTMENT_CREATE, APPOINTMENT_UPDATE, APPOINTMENT_DELETE, APPOINTMENT_EMAIL, APPOINTMENT_SMS, APPOINTMENT_PRINT
CustomersCUSTOMER_EXPORT, CUSTOMER_PRICING_EXPORT, CUSTOMER_DELETE, BULK_EMAIL, BULK_SMS
ServicesSERVICE_EXPORT, SERVICE_UPDATE, SERVICE_DELETE, SERVICE_EMAIL, SERVICE_SMS
InvoicesINVOICE (create), INVOICE_UPDATE, INVOICE_EXPORT
QuotesQUOTE_CREATION, QUOTE_UPDATE, QUOTE_EMAIL, QUOTE_SMS
InventoryINVENTORY_RESET

Some customer import/update types exist for first-party tooling and are not part of the integration surface.

Enqueuing an Operation

Each resource exposes its own enqueue mutation. They all return a BulkOperation right away — they do not wait for the work to finish. You either pass explicit record IDs or a selector (the same filter/search shape the list queries accept) to target records by criteria.

For example, export every active customer to a spreadsheet:

mutation ExportCustomers($input: BulkExportCustomersInput!) {
  bulkExportCustomers(input: $input) {
    id
    type
    status
    createdAt
  }
}

Response:

{
  "data": {
    "bulkExportCustomers": {
      "id": "bop_7d9f1a3c5e7b",
      "type": "CUSTOMER_EXPORT",
      "status": "PENDING",
      "createdAt": "2026-06-03T12:00:00.000Z"
    }
  }
}

Other enqueue mutations follow the same shape, for example: bulkCreateInvoicesV2, bulkUpdateInvoicesV2, bulkExportInvoices, bulkUpdateAppointmentsV2, bulkDeleteAppointmentsV2, bulkEmailAppointmentsV2, bulkExportServices, bulkUpdateServicesV2, and resetAllInventoryQuantities. Each returns a BulkOperation you track exactly as below.

Scopes are still enforced per resource

Enqueuing requires the normal scope for that action on that resource — for example read:customer to export customers, write:appointment to bulk-update appointments, delete:service to bulk-delete services. The bulk-operation scopes below only govern reading and managing the operations themselves.

The BulkOperation Object

FieldTypeDescription
idID!Unique identifier of the operation.
nameStringOptional human-friendly label you can set and edit.
typeBulkOperationType!The kind of operation (see the table above).
statusBulkOperationStatus!Current lifecycle status.
totalIntTotal number of records the operation will process.
progressBulkOperationProgressLive counters: total, succeeded, failed, skipped.
items[BulkOperationItem]Per-record results (prefer the paginated bulkOperationItems query for large jobs).
downloadUrlStringSigned URL to the generated file, for export operations once complete.
createdByUserThe user (or API key principal) that enqueued the operation.
startedAt / completedAtDateWhen processing began and finished.
createdAt / updatedAtDate!Creation and last-update timestamps.

Tracking Progress

Poll the operation by id. The progress object gives you a live rollup without paging through every item:

query BulkOperation($id: ID!) {
  bulkOperation(id: $id) {
    id
    type
    status
    total
    progress {
      total
      succeeded
      failed
      skipped
    }
    downloadUrl
  }
}

Response:

{
  "data": {
    "bulkOperation": {
      "id": "bop_7d9f1a3c5e7b",
      "type": "CUSTOMER_EXPORT",
      "status": "COMPLETED",
      "total": 128,
      "progress": { "total": 128, "succeeded": 128, "failed": 0, "skipped": 0 },
      "downloadUrl": "https://files.poolservicemanager.com/exports/...signed..."
    }
  }
}

A reasonable polling cadence is every few seconds until status reaches a terminal value. Both the operation list and the items list use cursor-based pagination.

Per-record Results

For row-level detail — especially to find what failed — query bulkOperationItems. Each item carries its status, an errorMessage when it failed, a JSON resultData payload, and the id of the affected record (whichever of customerId, serviceId, appointmentId, quoteId, invoiceId applies). Filter by status to fetch only the failures:

query FailedItems($operationId: ID!) {
  bulkOperationItems(
    operationId: $operationId
    filter: { status: FAILED }
    first: 50
  ) {
    edges {
      node {
        id
        status
        customerId
        errorMessage
        resultData
        processedAt
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Item status is one of SUCCEEDED, FAILED, or SKIPPED. Skipped rows are records the operation intentionally didn't act on (e.g. already in the target state or not eligible).

Downloading Export Results

Export operations (CUSTOMER_EXPORT, CUSTOMER_PRICING_EXPORT, SERVICE_EXPORT, INVOICE_EXPORT, and appointment APPOINTMENT_PRINT) produce a file. Once the operation completes, read the downloadUrl field — it resolves to a short-lived signed URL you can fetch directly. It is null until the file is ready, so only request it after the operation reaches a terminal status.

Managing Operations

Rename an operation for easier identification, or cancel one that is still PENDING or PROCESSING:

mutation RenameOperation {
  updateBulkOperationName(input: { id: "bop_7d9f1a3c5e7b", name: "March route export" }) {
    id
    name
  }
}

mutation CancelOperation {
  cancelBulkOperation(id: "bop_7d9f1a3c5e7b")
}

Cancelling moves the operation to CANCELLED; records already processed keep their results.

Listing Operations

Retrieve recent operations for the organization with bulkOperations. It accepts cursor pagination, a sort argument (by name, type, status, createdAt, or updatedAt), and a selector for filtering by type, status, created-date range, or a name search:

query {
  bulkOperations(
    first: 20
    selector: { filters: { status: PROCESSING } }
  ) {
    edges {
      node {
        id
        name
        type
        status
        progress { total succeeded failed skipped }
        createdAt
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Statuses

Operation statusMeaning
PENDINGEnqueued, not yet picked up by a worker.
PROCESSINGCurrently running; records are being processed.
COMPLETEDFinished with no failed records.
COMPLETED_WITH_ERRORSFinished, but one or more records failed — inspect the items.
FAILEDThe operation as a whole could not run.
CANCELLEDCancelled before it finished.

Scopes

ScopeGrants
read:bulk-operationRead operations and their items — bulkOperation, bulkOperations, bulkOperationItems.
edit:bulk-operationManage operations — updateBulkOperationName, cancelBulkOperation.

Enqueuing a bulk operation additionally requires the relevant resource scope for the action being performed.