> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.pivotal.app/llms.txt.
> For full documentation content, see https://docs.pivotal.app/llms-full.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.pivotal.app/_mcp/server.

# Integration sync

The most common Pivotal integration is a cron that pulls customers from your CRM (HubSpot, Salesforce, Stripe) into Pivotal so onboarding and CS workflows have the same source of truth as your revenue dashboard.

This guide walks the pattern. It assumes:

* A daily or hourly cron with access to a checkpoint store (Redis, Postgres, a JSON file in S3 — anything durable).
* A live Pivotal API key.
* A CRM that lets you query records updated since a timestamp.

## Shape of the sync

```
┌──────────┐    1. fetch updated since T    ┌──────────┐
│  Cron    │ ─────────────────────────────► │   CRM    │
└──────────┘                                 └──────────┘
     │                                            │
     │ 2. for each record                         │
     │                                            ▼
     │                                       (your records)
     ▼
┌──────────┐    3a. POST /customers if new        ┌──────────┐
│   Sync   │ ───────────────────────────────────► │ Pivotal  │
│  worker  │    3b. PATCH /customers/{id} if known│   API    │
└──────────┘ ───────────────────────────────────► └──────────┘
     │
     │ 4. checkpoint = max(crm_updated_at)
     ▼
┌──────────┐
│ Storage  │
└──────────┘
```

## Mapping CRM IDs to Pivotal customers

Two patterns, pick one and stick with it:

### Pattern A — store Pivotal's id on your side (preferred)

Add a `pivotal_customer_id` column to your CRM (or to a sidecar table). The first time you sync a record, create the Pivotal customer and save the returned `id`.

```typescript
async function ensurePivotalCustomer(crm: CrmRecord, store: Store): Promise<string> {
  const known = await store.get(crm.id);
  if (known) return known;

  const res = await fetch("https://my.pivotal.app/api/v1/customers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `crm-sync-${crm.id}`,
    },
    body: JSON.stringify({
      name: crm.name,
      slug: slugify(crm.name),
      domain: crm.domain,
      hubspot_company_id: crm.id,
      status: "onboarding",
    }),
  });
  const body = await res.json();
  if (!res.ok && body.error?.code !== "slug_taken") {
    throw new Error(`Pivotal POST failed: ${body.error?.code}`);
  }
  await store.set(crm.id, body.id);
  return body.id;
}
```

The `Idempotency-Key` belt-and-suspenders means a retry after a network blip doesn't double-create. The `slug_taken` recovery handles the case where someone created the customer manually before the sync ran.

### Pattern B — list and match by external id field

Skip the sidecar table. On each run, list all Pivotal customers and match on `hubspot_company_id` (or whichever CRM ID field you use):

```typescript
const res = await fetch(
  "https://my.pivotal.app/api/v1/customers?limit=100",
  { headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` } },
);
const page = await res.json();
const byHubspotId = new Map(page.data.map((c: any) => [c.hubspot_company_id, c.id]));
```

This works for small workspaces (\< 1000 customers) but gets expensive as you grow. Pattern A scales.

## Updating

Once you have the Pivotal `id` (or `display_id`), pushing updates is a `PATCH`:

```typescript
async function syncOne(crm: CrmRecord, pivotalId: string) {
  const res = await fetch(`https://my.pivotal.app/api/v1/customers/${pivotalId}`, {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: crm.name,
      domain: crm.domain,
      status: mapStatus(crm.lifecycle_stage),
    }),
  });
  if (!res.ok) {
    const body = await res.json();
    console.error(`Sync failed for ${crm.id}:`, body.error);
  }
}
```

Send only the fields you actually pulled from the CRM. `PATCH` is partial — omitted fields stay as Pivotal has them.

## Checkpointing

After processing a batch, save the highest `crm_updated_at` from the batch. On the next run, start from there:

```typescript
const lastRun = (await store.get("last_run")) ?? "2026-01-01T00:00:00Z";
const updated = await crm.companies.search({
  filter: `updated_at >= ${lastRun}`,
  sort: "updated_at",
  limit: 200,
});

let maxUpdated = lastRun;
for (const record of updated) {
  await syncOne(record, await ensurePivotalCustomer(record, store));
  if (record.updated_at > maxUpdated) maxUpdated = record.updated_at;
}
await store.set("last_run", maxUpdated);
```

If the worker crashes mid-batch, you re-process some records on the next run — idempotent because of `PATCH`.

## Throttle the worker

The Pivotal rate limit is 60 req/min on a default key. A sync of 500 customers will burn through that in 8–9 minutes. Keep concurrency low:

```typescript
import pLimit from "p-limit";
const limit = pLimit(4); // 4 in-flight requests
await Promise.all(records.map((r) => limit(() => syncOne(r, idMap.get(r.id)!))));
```

Or use a 600/min paid key — open a ticket from inside the workspace if you need one.

## Two-way: pulling from Pivotal

Polling Pivotal for changes uses cursor pagination ([Pagination](/api/welcome/pagination)). Until [webhooks](/api/welcome/webhooks) ship, this is the only way:

```typescript
const since = (await store.get("pivotal_cursor")) ?? new Date().toISOString();
let cursor: string | undefined;
const updates: any[] = [];

do {
  const url = new URL("https://my.pivotal.app/api/v1/customers");
  url.searchParams.set("limit", "100");
  if (cursor) url.searchParams.set("cursor", cursor);

  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` } });
  const page = await res.json();

  for (const c of page.data) {
    if (c.updated_at <= since) { cursor = undefined; break; } // walked far enough
    updates.push(c);
  }
  cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);

// Apply `updates` to your CRM in updated-at order
updates.sort((a, b) => a.updated_at.localeCompare(b.updated_at));
for (const u of updates) await pushToCrm(u);

await store.set("pivotal_cursor", updates.at(-1)?.updated_at ?? since);
```

## What to put in `metadata`

Most CRM-specific data (lead source, deal owner, contract value) doesn't have a first-class field on Pivotal's customer model. Use the `metadata` JSON field — it round-trips intact:

```json
{
  "metadata": {
    "hubspot_owner_id": "1432",
    "annual_contract_value_cents": 1200000,
    "lead_source": "outbound_email"
  }
}
```

Pivotal doesn't introspect `metadata`; it's a black box for your use. Search and filtering on metadata fields isn't supported via the API yet.