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
- Call an enqueue mutation (e.g.
bulkExportCustomers) with the records or a selector. It returns aBulkOperationwith statusPENDING. - The worker picks it up and moves it to
PROCESSING, creating aBulkOperationItemper record. - Poll
bulkOperation(id)forstatusandprogress, andbulkOperationItemsfor per-record outcomes. - The operation settles on a terminal status —
COMPLETED,COMPLETED_WITH_ERRORS,FAILED, orCANCELLED. For exports, adownloadUrlbecomes available.
Operation Types
Every operation has a type (BulkOperationType). You can filter the operations list by type. The available types, grouped by resource:
| Resource | Operation types |
|---|---|
| Appointments | APPOINTMENT_CREATE, APPOINTMENT_UPDATE, APPOINTMENT_DELETE, APPOINTMENT_EMAIL, APPOINTMENT_SMS, APPOINTMENT_PRINT |
| Customers | CUSTOMER_EXPORT, CUSTOMER_PRICING_EXPORT, CUSTOMER_DELETE, BULK_EMAIL, BULK_SMS |
| Services | SERVICE_EXPORT, SERVICE_UPDATE, SERVICE_DELETE, SERVICE_EMAIL, SERVICE_SMS |
| Invoices | INVOICE (create), INVOICE_UPDATE, INVOICE_EXPORT |
| Quotes | QUOTE_CREATION, QUOTE_UPDATE, QUOTE_EMAIL, QUOTE_SMS |
| Inventory | INVENTORY_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
| Field | Type | Description |
|---|---|---|
id | ID! | Unique identifier of the operation. |
name | String | Optional human-friendly label you can set and edit. |
type | BulkOperationType! | The kind of operation (see the table above). |
status | BulkOperationStatus! | Current lifecycle status. |
total | Int | Total number of records the operation will process. |
progress | BulkOperationProgress | Live counters: total, succeeded, failed, skipped. |
items | [BulkOperationItem] | Per-record results (prefer the paginated bulkOperationItems query for large jobs). |
downloadUrl | String | Signed URL to the generated file, for export operations once complete. |
createdBy | User | The user (or API key principal) that enqueued the operation. |
startedAt / completedAt | Date | When processing began and finished. |
createdAt / updatedAt | Date! | 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 status | Meaning |
|---|---|
PENDING | Enqueued, not yet picked up by a worker. |
PROCESSING | Currently running; records are being processed. |
COMPLETED | Finished with no failed records. |
COMPLETED_WITH_ERRORS | Finished, but one or more records failed — inspect the items. |
FAILED | The operation as a whole could not run. |
CANCELLED | Cancelled before it finished. |
Scopes
| Scope | Grants |
|---|---|
read:bulk-operation | Read operations and their items — bulkOperation, bulkOperations, bulkOperationItems. |
edit:bulk-operation | Manage operations — updateBulkOperationName, cancelBulkOperation. |
Enqueuing a bulk operation additionally requires the relevant resource scope for the action being performed.