> For the complete documentation index, see [llms.txt](https://docs.shortcutco.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.shortcutco.com/developers/concepts/sync.md).

# Sync

Keep another system in step with Shortcut using updatedSince and createdSince, with a watermark and a five-minute overlap.

After this page you can copy Shortcut records into another system and keep that copy current with one small loop, without missing a change and without re-reading everything.

## How incremental sync works

Lists that support sync take an `updatedSince` timestamp. With it, the list:

* returns every record whose `updatedAt` is **at or after** the timestamp (inclusive);
* is ordered by `updatedAt`, oldest change first, then by `id`;
* pages with a cursor on that same order, so records that changed in the same millisecond are never skipped between pages.

Without `updatedSince`, lists are ordered newest first by `createdAt`, which is right for browsing and wrong for sync.

```bash
curl "https://api.shortcutco.com/v1/visits?updatedSince=2026-10-16T13:55:00Z&limit=200" \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

```json
{
  "data": [
    {
      "id": "5e0c8a1f-2b7d-4c3e-9f6a-8d1b2c3e4f50",
      "object": "visit",
      "date": "2026-10-16",
      "status": "completed",
      "closeReason": null,
      "jobId": "a4f1c9e2-7b3d-4e8a-9c5f-1d2e3f4a5b6c",
      "jobName": "Weekly mowing",
      "customerId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "propertyId": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
      "address": "412 Elm St, Bozeman, MT 59715",
      "assignedTeamId": null,
      "effectiveTeamId": "6c7d8e9f-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
      "routePosition": 3,
      "plannedStartAt": "2026-10-16T16:30:00.000Z",
      "plannedEndAt": "2026-10-16T17:00:00.000Z",
      "estimatedDurationMinutes": 30,
      "arrivedAt": "2026-10-16T16:34:51.000Z",
      "completedAt": "2026-10-16T17:02:18.000Z",
      "invoiceId": null,
      "createdAt": "2026-04-02T15:12:44.120Z",
      "updatedAt": "2026-10-16T17:02:18.640Z"
    }
  ],
  "meta": { "nextCursor": null, "hasMore": false }
}
```

These lists take `updatedSince`: `GET /v1/customers`, `GET /v1/customers/{id}/properties`, `GET /v1/leads`, `GET /v1/estimates`, `GET /v1/jobs`, `GET /v1/jobs/{id}/visits`, `GET /v1/visits`, `GET /v1/services`, `GET /v1/products`, `GET /v1/teams`, `GET /v1/users`, `GET /v1/tags`, and `GET /v1/invoices`. `GET /v1/payments` takes `createdSince` instead; see [below](#payments-sync-on-createdsince). The [OpenAPI description](https://api.shortcutco.com/v1/openapi.json) lists every filter each list takes.

## The sync loop

1. Keep a **watermark** per resource: the largest `updatedAt` you have stored.
2. On each run, ask for `updatedSince` = watermark **minus five minutes**.
3. Page until `meta.hasMore` is `false`, upserting each record into your copy **by `id`**.
4. Save the largest `updatedAt` you saw as the new watermark.

The five-minute overlap covers writes that were in flight when your last run read the list: a change whose transaction started before your read but finished after it carries an `updatedAt` slightly earlier than records you already saw. Re-reading the last five minutes picks those up. The cost is that each run sees a few records again, which is why step 3 upserts by `id` instead of inserting.

For the first run, start from `1970-01-01T00:00:00Z`. The same loop then copies everything, oldest first, and leaves you with a watermark.

```js
// sync-customers.mjs: run every few minutes. Node 18 or later.
const OVERLAP_MS = 5 * 60 * 1000;

export async function syncCustomers(store) {
  const watermark = (await store.getWatermark("customers")) ?? "1970-01-01T00:00:00.000Z";
  const since = new Date(Date.parse(watermark) - OVERLAP_MS).toISOString();
  let newest = watermark;
  let cursor = null;

  do {
    const url = new URL("https://api.shortcutco.com/v1/customers");
    url.searchParams.set("updatedSince", since);
    url.searchParams.set("limit", "200");
    if (cursor) url.searchParams.set("cursor", cursor);

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.SHORTCUT_TOKEN}` },
    });
    if (!response.ok) {
      const { error } = await response.json();
      throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`);
    }
    const page = await response.json();

    for (const customer of page.data) {
      // Upsert by id. A record seen twice is written twice; the second write is a no-op.
      await store.upsert("customers", customer.id, customer);
      if (customer.updatedAt > newest) newest = customer.updatedAt;
    }
    cursor = page.meta.nextCursor;
  } while (cursor);

  await store.setWatermark("customers", newest);
}
```

Save the watermark only after the whole run succeeds. If a run fails halfway, the next run starts from the old watermark and repeats the pages it already stored, which the upsert absorbs.

Timestamps in responses are all UTC with the same format, so comparing them as strings, as above, orders them correctly.

## What changes `updatedAt`

A record's `updatedAt` changes when that record changes. Related records sync from their own lists.

* A **customer's** `updatedAt` also changes when a tag is attached to or removed from them (deleting a tag they wear counts) and when one of their custom-field values changes. Saving the same tags or values again changes nothing. Their properties sync from `GET /v1/customers/{id}/properties`; a property change does not touch the customer.
* A **visit's** `updatedAt` changes when the visit itself changes: its date, status, crew, route position, or times. Its `jobName`, `address`, and `customerId` come from its job and property, and an inherited `effectiveTeamId` comes from its job's crew; changes there do not touch the visit. To keep those current, re-read the visits you care about, such as the coming week's with `from` and `to`.
* A **job's** `updatedAt` changes when the job itself changes: its name, status, price, cadence, or crew. Its `nextVisitId`, `nextVisitDate`, `effectiveTeamId`, and `address` come from its visits and property, so sync visits from their own list. A price change in the app creates a new job, linked to the earlier one by `previousVersionJobId`; the earlier one gets an `effectiveUntilDate`, and both come back in sync.
* A **crew's** `updatedAt` does not change when people join or leave it. To keep membership current, re-list crews, or sync staff, whose `teamId` changes with their crew.
* An **invoice** turning `overdue` does not change its `updatedAt`: the status changes on its own when `dueAt` passes. Re-read open invoices directly, for example `GET /v1/invoices?status=unpaid` and `GET /v1/invoices?status=overdue`, rather than relying on sync to see them go overdue.
* **Deactivating** a customer or a service, **archiving** a product or a crew, **dismissing** a lead, **canceling** a job or a visit, or **voiding** an invoice is a change like any other: the record comes back with its new `status`. Shortcut keeps these records rather than deleting them, so sync never needs a separate deletions feed.
* **Tags** and undone manual payments are the records that are deleted outright. Tags are few, so re-list them in full when you need to notice a deleted tag. The customers who wore a deleted tag come back through `updatedSince` without it.
* **Imports** of many records at once, such as a CSV of customers, show up through `updatedSince` like any other change.

## External references

To sync customers, properties, leads, and jobs both ways, store your own id on the Shortcut record instead of keeping a mapping table on your side. An external reference is a `source` you choose (`hubspot`, `quickbooks`, `mywebsite`: lowercase letters, digits, `_`, `.`, `-`) plus your `externalId`. Each one belongs to exactly one record of its kind in the workspace, and records carry theirs in `externalReferences`.

| Call                                                        | What it does                                                                                |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `GET /v1/customers/external/{source}/{externalId}`          | The customer your id points to, or `404`.                                                   |
| `PUT /v1/customers/external/{source}/{externalId}`          | The upsert: creates the customer if your id is new, otherwise updates the one it points to. |
| `GET /v1/customers?externalSource=hubspot&externalId=51234` | The same lookup as a list filter. Send both parameters or neither.                          |
| `POST /v1/customers` with `externalReference`               | Creates the customer and saves your id in the same request.                                 |
| `PUT /v1/customers/{id}/external-references/{source}`       | Links an existing customer to your id, with body `{ "externalId": "…" }`.                   |
| `DELETE /v1/customers/{id}/external-references/{source}`    | Unlinks it. The customer itself is unchanged.                                               |

Properties have the same calls (`GET` and `PUT /v1/properties/external/{source}/{externalId}`, and `PUT` and `DELETE /v1/properties/{id}/external-references/{source}`), and `POST /v1/customers/{id}/properties` takes `externalReference` too. The property upsert also needs `customerId`.

Leads and jobs have them as well:

| Call                                                           | What it does                                              |
| -------------------------------------------------------------- | --------------------------------------------------------- |
| `GET` and `PUT /v1/leads/external/{source}/{externalId}`       | Look up, or create or update, the lead your id points to. |
| `GET /v1/leads?externalSource=mywebsite&externalId=form-881`   | The lead lookup as a list filter.                         |
| `POST /v1/leads` with `externalReference`                      | Creates the lead and saves your id in the same request.   |
| `PUT` and `DELETE /v1/leads/{id}/external-references/{source}` | Link or unlink an existing lead.                          |
| `GET` and `PUT /v1/jobs/external/{source}/{externalId}`        | Look up, or create or update, the job your id points to.  |
| `GET /v1/jobs?externalSource=fieldops&externalId=wo-1042`      | The job lookup as a list filter.                          |
| `POST /v1/jobs` with `externalReference` on a service          | Saves your id for the job that service creates.           |
| `PUT` and `DELETE /v1/jobs/{id}/external-references/{source}`  | Link or unlink an existing job.                           |

The lead upsert creates a lead exactly as `POST /v1/leads` does, with the same rules: it counts toward the 20 leads a minute per workspace, an email that belongs to an existing customer needs `customers:write`, and the new-lead automations run only with `automations:trigger`. For a known id it applies the contact fields (names, company, email, phone) and ignores `address`, `servicesRequested`, and `specialRequests`.

The job upsert takes one job as a service of `POST /v1/jobs` describes it, plus `customerId` and, optionally, `propertyId`, `name`, `description`, and `teamId`. For a known id it applies `name`, `description`, and `teamId` and ignores the property, service, cadence, and price. Sending the job's current crew again leaves its visits alone. A `customerId` other than the job's customer is `409 external_reference_customer_mismatch`. A one-time job ignores `teamId: null`, so a workspace with one crew can sync one-time jobs.

A price change in the app replaces a job with a new version: the old job gets an `effectiveUntilDate`, and the new one points back to it with `previousVersionJobId`. Your external id moves to the new version in the same change, so the lookup, the upsert, and the list filter keep finding the job that is current. A request that still names the replaced version (for example, linking it by its old id) is `409 job_superseded`, and the message names the current job.

```bash
curl https://api.shortcutco.com/v1/jobs/external/fieldops/wo-1042 \
  -X PUT \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "customerId": "3f6c1a52-8d0e-4b7a-9c21-5e4f7a1b2c3d", "serviceId": "7a2d9e41-6b3c-4f58-a1e0-2c9d8b7f6e5a", "cadence": "recurring", "cadenceFrequency": 1, "cadencePeriod": "per_week", "rate": 55 }'
```

The upsert is the sync call. Send the whole record every time:

```bash
curl https://api.shortcutco.com/v1/customers/external/hubspot/51234 \
  -X PUT \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "firstName": "Robin", "lastName": "Alvarez", "email": "robin.alvarez@example.com", "property": { "address": "412 Elm St, Bozeman, MT 59715" } }'
```

* **New id:** the customer is created with their first property, as `POST /v1/customers` does. The answer is `201` with `data.created: true`.
* **Known id:** the fields that differ from what Shortcut has are applied, as `PATCH /v1/customers/{id}` does, and `property` is ignored (manage properties with the property endpoints). The answer is `200` with `data.created: false`. A request that changes nothing writes nothing, so it does not move `updatedAt` or queue a QuickBooks sync.
* **Two requests for the same id at once** run one after another, so they never create two customers. The same holds for every upsert.

Linking is always explicit. Shortcut never matches your id to an existing customer by email or address: an upsert with a new id creates a customer, and if that email already belongs to a customer the request fails with `409 email_in_use`. To adopt existing customers, link each one with `PUT /v1/customers/{id}/external-references/{source}`. An id that already belongs to another record is `409 already_exists`, and the message names that record.

The property upsert treats an unchanged address string as unchanged, whatever coordinates you send with it. Moving the address to another spot clears a measured property map, so it needs `confirmClearPropertyMap: true`; see [`property_map_would_clear`](/developers/concepts/errors.md#property_map_would_clear).

## Payments sync on `createdSince`

Payments have no `updatedAt`. Their list takes `createdSince` instead, with the same rules: inclusive, oldest first, keep the largest `createdAt` as the watermark, subtract five minutes, upsert by `id`.

When the workspace undoes a manual payment, the payment is removed and its invoice's `updatedAt` changes. Sync invoices alongside payments, and when an invoice changes, re-read its payments with the `invoiceId` filter.

A refund that succeeds later changes the payment's `refundedAmount` without changing the payment or its invoice, so neither `createdSince` nor `updatedSince` picks it up. To see refunds, re-read the payments you need current: one at a time with `GET /v1/payments/{id}`, or a recent window again with `GET /v1/payments?createdSince=…`.
