> 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/guides/sync-customers-from-your-crm.md).

# Sync customers from your CRM

Keep customers in step between your CRM and Shortcut, keyed by your CRM's own ids, in both directions.

After this guide, every contact in your CRM that becomes a customer has exactly one matching customer in Shortcut, changes in the CRM reach Shortcut on the next run, and changes made in Shortcut flow back. You never keep a table of which CRM contact is which Shortcut customer: Shortcut stores your CRM's id on the customer as an external reference.

You need a server or scheduled job that can read your CRM, and about 30 minutes.

## 1. Create a token

In **Settings → API & integrations**, create a token named after the CRM, such as `HubSpot sync`, with **Custom** scopes: **Write customers** (it includes reading them). Leave **Trigger automations** off unless customers the sync creates should start your automations as customers added in the app do.

## 2. Pick a source name

Your CRM's ids are stored under a `source` you choose: `hubspot`, `salesforce`, `mycrm`. Use 1 to 64 lowercase letters, digits, `_`, `.`, or `-`, starting with a letter or digit, and never change it: the source and the CRM's id together are the key.

## 3. Push CRM contacts with the upsert

`PUT /v1/customers/external/{source}/{externalId}` creates the customer when your id is new and updates it when it exists, in one request:

```bash
curl https://api.shortcutco.com/v1/customers/external/hubspot/58213 \
  -X PUT \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: hubspot-58213-2026-10-17T09:00" \
  -d '{
    "firstName": "Dana",
    "lastName": "Okafor",
    "companyName": "Okafor Dental",
    "email": "office@okafordental.example",
    "phone": "(406) 555-0198",
    "property": { "address": "1180 N 19th Ave, Bozeman, MT 59718", "label": "Office" }
  }'
```

```json
{
  "data": {
    "created": true,
    "customer": {
      "id": "4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09",
      "object": "customer",
      "firstName": "Dana",
      "lastName": "Okafor",
      "companyName": "Okafor Dental",
      "email": "office@okafordental.example",
      "phone": "(406) 555-0198",
      "status": "active",
      "tags": [],
      "externalReferences": [{ "source": "hubspot", "externalId": "58213" }],
      "createdAt": "2026-10-17T15:00:02.114Z",
      "updatedAt": "2026-10-17T15:00:02.114Z",
      "properties": [
        {
          "id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
          "object": "property",
          "label": "Office",
          "address": "1180 N 19th Ave, Bozeman, MT 59718",
          "latitude": null,
          "longitude": null,
          "squareFeet": null,
          "status": "active",
          "createdAt": "2026-10-17T15:00:02.114Z",
          "updatedAt": "2026-10-17T15:00:02.114Z"
        }
      ]
    }
  }
}
```

* The first request creates the customer with its first property and answers `201` with `created: true`.
* Later requests with the same CRM id answer `200` with `created: false`. They change only the contact fields that differ and ignore `property`, so a customer's addresses stay as your team keeps them in Shortcut. A request that changes nothing writes nothing.
* Two requests for the same id at the same moment never create two customers: the second waits for the first.
* An email that already belongs to a different customer or a team member is refused, so a typo in the CRM never merges two people.

A small sync job:

```js
// push-crm-contacts.mjs: Node 18 or later.
const SOURCE = "hubspot";

export async function pushContact(contact) {
  const url = `https://api.shortcutco.com/v1/customers/external/${SOURCE}/${encodeURIComponent(contact.id)}`;
  const response = await fetch(url, {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${process.env.SHORTCUT_TOKEN}`,
      "Content-Type": "application/json",
      // Same key for retries of this contact version; a new version gets a new key.
      "Idempotency-Key": `${SOURCE}-${contact.id}-${contact.updatedAt}`,
    },
    body: JSON.stringify({
      firstName: contact.firstName,
      lastName: contact.lastName,
      companyName: contact.company || null,
      ...(contact.email ? { email: contact.email } : {}),
      ...(contact.phone ? { phone: contact.phone } : {}),
      property: { address: contact.serviceAddress },
    }),
  });
  const payload = await response.json();
  if (!response.ok) {
    const { code, message, requestId } = payload.error;
    throw new Error(`${contact.id}: ${code}: ${message} (request ${requestId})`);
  }
  return payload.data;
}
```

Every upsert needs `property`, even though updates ignore it, so skip contacts without a service address until they have one. Run it for every contact your CRM changed since the last run. Keep going after a failed contact and report the failures: one bad record should not stop the rest.

## 4. Pull changes made in Shortcut

Changes your team makes in Shortcut, such as a corrected phone number or a deactivated customer, come back through `updatedSince`. Use the watermark loop from [Sync](/developers/concepts/sync.md), and map each customer back to the CRM with `externalReferences`:

```js
// pull-shortcut-customers.mjs: Node 18 or later.
const SOURCE = "hubspot";
const OVERLAP_MS = 5 * 60 * 1000;

export async function pullCustomers(store, crm) {
  const watermark = (await store.getWatermark("shortcut-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}` } });
    const page = await response.json();
    if (!response.ok) throw new Error(`${page.error.code}: ${page.error.message}`);

    for (const customer of page.data) {
      const reference = customer.externalReferences.find((entry) => entry.source === SOURCE);
      if (reference) {
        await crm.updateContact(reference.externalId, {
          phone: customer.phone,
          email: customer.email,
          shortcutStatus: customer.status,
        });
      }
      if (customer.updatedAt > newest) newest = customer.updatedAt;
    }
    cursor = page.meta.nextCursor;
  } while (cursor);
  await store.setWatermark("shortcut-customers", newest);
}
```

Your own pushes come back through this loop too. Compare the fields before writing to the CRM, so the two sides settle instead of echoing each other.

To hear about changes within a minute instead of on the next run, subscribe to the `customer.created`, `customer.updated`, and `customer.deactivated` webhooks and run the pull when one arrives. Creating the webhook needs a token with **Manage webhooks** as well as read access to customers; **Write customers** covers that, so add **Manage webhooks** to the sync's token, or have a company admin create the webhook in **Settings → API & integrations**. See [Webhooks](/developers/webhooks/overview.md).

## 5. Link customers that already exist

If a customer is already in Shortcut, link it to its CRM contact instead of creating a second one:

```bash
curl https://api.shortcutco.com/v1/customers/4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09/external-references/hubspot \
  -X PUT \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "58213" }'
```

Find the Shortcut customer first, by email with `GET /v1/customers?q=office@okafordental.example`, and link only clear matches. From then on, the upsert in step 3 updates that customer. `DELETE` on the same address unlinks it.

## Reference

* [The customer object](/developers/api-reference/reference/objects/customer.md): every field, and the upsert, lookup by your id, and link and unlink endpoints.
* [The property object](/developers/api-reference/reference/objects/property.md): addresses, which the upsert leaves to your team after the first request.
* [Sync](/developers/concepts/sync.md): `updatedSince` and the five-minute overlap.
