For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
DashboardGet an API key
Get StartedGuidesAPI ReferenceSDKsChangelog
Get StartedGuidesAPI ReferenceSDKsChangelog
  • Common workflows
    • Create your first customer
    • Onboarding phases
    • Integration sync
    • display_id vs id
    • Bulk operations
    • Test mode
LogoLogo
DashboardGet an API key
On this page
  • Shape of the sync
  • Mapping CRM IDs to Pivotal customers
  • Pattern A — store Pivotal’s id on your side (preferred)
  • Pattern B — list and match by external id field
  • Updating
  • Checkpointing
  • Throttle the worker
  • Two-way: pulling from Pivotal
  • What to put in metadata
Common workflows

Integration sync

Mirror customers from your CRM into Pivotal and keep both sides in step.
|View as Markdown|Open in Claude|
Was this page helpful?
Previous

Onboarding phases

Next

display_id vs id

Built with

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.

1async function ensurePivotalCustomer(crm: CrmRecord, store: Store): Promise<string> {
2 const known = await store.get(crm.id);
3 if (known) return known;
4
5 const res = await fetch("https://my.pivotal.app/api/v1/customers", {
6 method: "POST",
7 headers: {
8 Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}`,
9 "Content-Type": "application/json",
10 "Idempotency-Key": `crm-sync-${crm.id}`,
11 },
12 body: JSON.stringify({
13 name: crm.name,
14 slug: slugify(crm.name),
15 domain: crm.domain,
16 hubspot_company_id: crm.id,
17 status: "onboarding",
18 }),
19 });
20 const body = await res.json();
21 if (!res.ok && body.error?.code !== "slug_taken") {
22 throw new Error(`Pivotal POST failed: ${body.error?.code}`);
23 }
24 await store.set(crm.id, body.id);
25 return body.id;
26}

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):

1const res = await fetch(
2 "https://my.pivotal.app/api/v1/customers?limit=100",
3 { headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` } },
4);
5const page = await res.json();
6const 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:

1async function syncOne(crm: CrmRecord, pivotalId: string) {
2 const res = await fetch(`https://my.pivotal.app/api/v1/customers/${pivotalId}`, {
3 method: "PATCH",
4 headers: {
5 Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}`,
6 "Content-Type": "application/json",
7 },
8 body: JSON.stringify({
9 name: crm.name,
10 domain: crm.domain,
11 status: mapStatus(crm.lifecycle_stage),
12 }),
13 });
14 if (!res.ok) {
15 const body = await res.json();
16 console.error(`Sync failed for ${crm.id}:`, body.error);
17 }
18}

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:

1const lastRun = (await store.get("last_run")) ?? "2026-01-01T00:00:00Z";
2const updated = await crm.companies.search({
3 filter: `updated_at >= ${lastRun}`,
4 sort: "updated_at",
5 limit: 200,
6});
7
8let maxUpdated = lastRun;
9for (const record of updated) {
10 await syncOne(record, await ensurePivotalCustomer(record, store));
11 if (record.updated_at > maxUpdated) maxUpdated = record.updated_at;
12}
13await 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:

1import pLimit from "p-limit";
2const limit = pLimit(4); // 4 in-flight requests
3await 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). Until webhooks ship, this is the only way:

1const since = (await store.get("pivotal_cursor")) ?? new Date().toISOString();
2let cursor: string | undefined;
3const updates: any[] = [];
4
5do {
6 const url = new URL("https://my.pivotal.app/api/v1/customers");
7 url.searchParams.set("limit", "100");
8 if (cursor) url.searchParams.set("cursor", cursor);
9
10 const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` } });
11 const page = await res.json();
12
13 for (const c of page.data) {
14 if (c.updated_at <= since) { cursor = undefined; break; } // walked far enough
15 updates.push(c);
16 }
17 cursor = page.has_more ? page.next_cursor : undefined;
18} while (cursor);
19
20// Apply `updates` to your CRM in updated-at order
21updates.sort((a, b) => a.updated_at.localeCompare(b.updated_at));
22for (const u of updates) await pushToCrm(u);
23
24await 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:

1{
2 "metadata": {
3 "hubspot_owner_id": "1432",
4 "annual_contract_value_cents": 1200000,
5 "lead_source": "outbound_email"
6 }
7}

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.