# Pivotal Docs
# Pivotal developer documentation
The Pivotal API and webhooks expose the same primitives the Pivotal app uses internally: customers, contacts, onboardings, and tasks. Use them to mirror customers from your CRM, attach contacts as you collect them, drive onboardings through their phases, and stream events into your stack.
***
SURFACES
Bearer auth, JSON in, JSON out. Every endpoint mirrors what the app does internally. Cursor pagination, idempotency keys, soft deletes, ISO 8601 dates, `snake_case` everywhere.
Subscribe to events, pick the types you care about, receive signed payloads. Delivered via Svix with 24h exponential backoff and a replay button in the dashboard.
End-user help for the Pivotal app. Setup, customers, onboardings, integrations, Workbench, and Ask Pi. The non-developer-facing surface.
***
DATA MODEL
Three first-class objects. Tasks live under onboardings.
Customer
central record
Contacts
N per customer
Onboardings
N per customer
Tasks
N per onboarding
***
DEVELOPER TOOLS
`npm i @pivotal/api`. Strict types from the OpenAPI spec.
`pip install pivotal`. Same shape as the TS client.
Generate a client in any language from the live spec.
Replay-safe POSTs. Drop a key on the header, retry without dupes.
`pivotal_test_` keys write to the same workspace, isolated by a flag.
Single-file context (`/llms-full.txt`), `.md` URL suffix, Open in Claude / ChatGPT on every page.
***
## Get started
Live (`pivotal_…`) or test (`pivotal_test_…`). Scoped to one workspace.
Key to customer to contact to onboarding in three minutes.
Every endpoint, every shape. Run requests from inside the docs.
Subscribe an endpoint, pick events, verify signatures, replay failures.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with the request you ran, the response body, and the response headers. For something time-sensitive, include "P0" in the subject.
# Introduction
Pivotal is a customer onboarding and customer-success platform. The Pivotal API exposes the same primitives the app uses internally: **customers**, **contacts**, and **onboardings**. Use it to mirror customers from your CRM, attach contacts as you collect them, kick off onboardings on a closed-won webhook, and read back state for dashboards.
## What you can build
Backfill from HubSpot or Stripe and keep both sides in step with a nightly job.
Create a Customer, attach a primary Contact, then open an Onboarding when a deal closes.
Pull active onboardings into your BI tool or render them inside an internal app.
Both `display_id` (numeric, short) and the canonical cuid resolve at every endpoint.
## Design choices
Lists return `{ object: "list", data: [...], has_more, next_cursor }`. Objects return `{ object: "customer", id, display_id, ... }`. Errors return `{ error: { type, code, message, field? } }`. The shape never changes between endpoints.
Send an API key in the `Authorization` header. Live keys start with `pivotal_`, test keys with `pivotal_test_`. Keys are scoped to one workspace.
List endpoints accept `limit` (max 100) and `cursor` (ISO 8601 timestamp). Pass `response.next_cursor` straight back as the next request's `cursor`. No total counts — counting at scale is expensive and rarely needed.
`DELETE` returns `{ object: "deleted", id, display_id, deleted: true }` and hides the row from subsequent reads. The record stays in the database for audit. Reach out if you need a hard delete.
`id` is a cuid — stable, opaque, safe in any system. `display_id` is a small integer used in URLs like `/customers/42`. Pass either to any `{id}` parameter. New objects always return both.
## Conventions in this reference
* **Production base URL:** `https://my.pivotal.app/api/v1`
* **Content type:** `application/json` for request and response bodies
* **Dates:** ISO 8601 in UTC (e.g. `2026-05-26T17:21:09.412Z`)
* **Casing:** request and response fields are `snake_case`; SDKs convert to language-idiomatic case
* **HTTP methods:** `GET` to read, `POST` to create, `PATCH` for partial updates, `DELETE` for soft deletes — `PUT` is not used
Open the [Quickstart](/welcome/quickstart) when you're ready to make your first call. The whole loop — install key, create customer, attach contact, start onboarding — takes a few minutes.
# Quickstart
This walkthrough takes you from an empty workspace to a customer, contact, and onboarding visible in the Pivotal UI. Total wall time: about three minutes.
## 1. Create an API key
Open [Admin > API Keys](https://my.pivotal.app/admin/api-keys) inside your workspace and click **Create key**. Pick a name (`local-dev`, `prod-server`, `ci`) and the environment:
* **Live** — `pivotal_…` — mutates production state, fires webhooks, counts against the live quota.
* **Test** — `pivotal_test_…` — mutates production state too, but skips outbound integration calls (Slack, Resend, Stripe). See [Test mode](/api/guides/test-mode).
Copy the key once and stash it in your secret manager. Pivotal stores only a hash; the cleartext is shown exactly once at create time.
Treat keys like passwords. They grant full read/write across the workspace. Rotate by creating a new key, swapping it in your services, then revoking the old one — there is no "downtime" between create and revoke.
## 2. Confirm the key works
Hit `/me`. It echoes the key's identity and the workspace it belongs to. No side effects, costs one request from your rate limit.
```bash title="curl"
curl https://my.pivotal.app/api/v1/me \
-H "Authorization: Bearer pivotal_REPLACE_ME"
```
```typescript title="Node (fetch)"
const res = await fetch("https://my.pivotal.app/api/v1/me", {
headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` },
});
const me = await res.json();
console.log(me);
```
```python title="Python (requests)"
import os, requests
res = requests.get(
"https://my.pivotal.app/api/v1/me",
headers={"Authorization": f"Bearer {os.environ['PIVOTAL_API_KEY']}"},
)
print(res.json())
```
```go title="Go (net/http)"
req, _ := http.NewRequest("GET", "https://my.pivotal.app/api/v1/me", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("PIVOTAL_API_KEY"))
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
```
You should see something like:
```json
{
"object": "api_key_identity",
"key_name": "local-dev",
"key_prefix": "pivotal_abc1",
"org_id": "org_2pXh…",
"org_slug": "vml",
"mode": "live"
}
```
If you get a `401`, double-check the header format — it's `Authorization: Bearer `, not `Bearer: ` or `Authorization: `.
## 3. Create a customer
```bash title="curl"
curl https://my.pivotal.app/api/v1/customers \
-H "Authorization: Bearer pivotal_REPLACE_ME" \
-H "Content-Type: application/json" \
-d '{
"name": "Aurora Outfitters",
"slug": "aurora",
"domain": "aurora.com",
"status": "onboarding"
}'
```
```typescript title="Node (fetch)"
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",
},
body: JSON.stringify({
name: "Aurora Outfitters",
slug: "aurora",
domain: "aurora.com",
status: "onboarding",
}),
});
const customer = await res.json();
```
```python title="Python (requests)"
res = requests.post(
"https://my.pivotal.app/api/v1/customers",
headers={
"Authorization": f"Bearer {os.environ['PIVOTAL_API_KEY']}",
"Content-Type": "application/json",
},
json={
"name": "Aurora Outfitters",
"slug": "aurora",
"domain": "aurora.com",
"status": "onboarding",
},
)
customer = res.json()
```
```go title="Go (net/http)"
body, _ := json.Marshal(map[string]any{
"name": "Aurora Outfitters",
"slug": "aurora",
"domain": "aurora.com",
"status": "onboarding",
})
req, _ := http.NewRequest("POST", "https://my.pivotal.app/api/v1/customers", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("PIVOTAL_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
```
`slug` is the URL-safe handle. Pivotal also returns a `display_id` (the small integer that appears in URLs like `/customers/245`).
```json
{
"object": "customer",
"id": "cl9bn00of0000g7l8gcq8c4xk",
"display_id": 245,
"name": "Aurora Outfitters",
"slug": "aurora",
"domain": "aurora.com",
"status": "onboarding",
"created_at": "2026-05-26T17:21:09.412Z",
"updated_at": "2026-05-26T17:21:09.412Z"
}
```
## 4. Attach a primary contact
```bash
curl https://my.pivotal.app/api/v1/customers/245/contacts \
-H "Authorization: Bearer pivotal_REPLACE_ME" \
-H "Content-Type: application/json" \
-d '{
"name": "Mira Chen",
"email": "mira@aurora.com",
"title": "Head of Ops",
"is_primary": true
}'
```
Note the path — contacts always live under a customer. The `245` is the customer's `display_id`; the customer's cuid works just as well.
## 5. Open an onboarding
```bash
curl https://my.pivotal.app/api/v1/onboardings \
-H "Authorization: Bearer pivotal_REPLACE_ME" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "245",
"phase": "before_getting_started",
"state": "active",
"target_launch_date": "2026-07-15T00:00:00Z"
}'
```
Refresh `/customers/aurora` in the Pivotal UI. You should see Mira on the customer card and the loyalty launch onboarding in the sidebar. The activity feed will show an entry for each API call — the calling key's name is the actor.
## Next
Key formats, rotation, and the difference between live and test.
Every error code you can get and what to do about it.
Safe retries on `POST` with `Idempotency-Key`.
Walk a list end to end without missing rows.
# Authentication
Every request to the Pivotal API carries an API key in the `Authorization` header:
```
Authorization: Bearer pivotal_K8s9X2mPq…
```
Keys are created from [Admin > API Keys](https://my.pivotal.app/admin/api-keys) inside the workspace they should access. There is no OAuth flow, no exchange step, and no signed request scheme — Bearer auth over TLS is the entire mechanism.
## Key formats
| Prefix | Mode | What it does |
| ---------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pivotal_…` | Live | Mutates production state, fires webhooks, sends real emails and Slack notifications via configured integrations. |
| `pivotal_test_…` | Test | Mutates production state, but suppresses outbound integration side effects (Slack, Resend, Stripe). Sits on a separate rate-limit bucket. See [Test mode](/api/guides/test-mode). |
The cleartext key is shown exactly once — at create time, in a copy-able banner. Pivotal stores only a PBKDF2-SHA256 hash. Lose the key and you have to create a new one.
The visible **key prefix** (e.g. `pivotal_K8s9`) is safe to log and appears on the API Keys page so you can match revoked keys to deployments. The remaining bytes are the secret half.
## Sending the key
```bash title="curl"
curl https://my.pivotal.app/api/v1/me \
-H "Authorization: Bearer $PIVOTAL_API_KEY"
```
```typescript title="Node"
const res = await fetch("https://my.pivotal.app/api/v1/me", {
headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` },
});
```
```python title="Python"
import os, requests
res = requests.get(
"https://my.pivotal.app/api/v1/me",
headers={"Authorization": f"Bearer {os.environ['PIVOTAL_API_KEY']}"},
)
```
Common header mistakes that return `401`:
* Missing `Bearer` prefix (`Authorization: pivotal_…`)
* Extra colon (`Bearer: pivotal_…`)
* Lowercase `bearer` — Pivotal accepts it, but some intermediaries strip it
* Trailing whitespace from a copy/paste
## Scope
Every key is scoped to exactly one workspace (one Pivotal "org"). A key for the `vml` workspace cannot read or write data in the `aurora` workspace, and vice versa. There is no cross-workspace key.
Inside a workspace, a key carries **full read/write** across all resources. There is no per-endpoint or per-resource scoping yet. Treat any key like a workspace admin password.
## Rotation
Pivotal supports concurrent active keys so rotation is zero-downtime:
1. Create a new key in [Admin > API Keys](https://my.pivotal.app/admin/api-keys).
2. Roll it out to your servers (env var swap + restart).
3. Confirm the old key's last-used time stops advancing.
4. Click **Revoke** on the old key.
A revoked key returns `401 authentication_error / api_key_revoked` instantly on the next request — there is no grace period.
## Storing keys
Don't:
* Commit keys to git, even in a private repo.
* Send keys in URL query strings (`?api_key=…`). The header is the only supported transport, and URL params end up in access logs and referer headers.
* Embed keys in browser code. The API is server-to-server; a key in front-end JS hands the workspace to anyone who opens DevTools.
Do:
* Inject keys at runtime from your secret manager (Vault, AWS Secrets Manager, Doppler, 1Password CLI).
* Use a different key per environment (`dev`, `staging`, `prod`) so revoking one is surgical.
* Use a different key per service when you can — the API Keys page shows last-used timestamps, which makes blast-radius investigation faster after an incident.
## Detecting compromise
Each key's row shows **last used at**, **last used IP** (where available), and **request count**. Anything unexpected — a key marked "ci" hitting from a random VPS, a "local-dev" key still active after the laptop was sold — is a signal to revoke.
If a key leaks, revoke first, investigate second. Revoke is one click and reversible (you can re-create a key with the same name); waiting to revoke while you investigate is not.
## Webhooks (preview)
Outbound webhooks are not yet part of the public API. The placeholder page at [Webhooks](/welcome/webhooks) describes the planned shape. For now, poll the relevant list endpoints with `?cursor=` to stay in sync.
# Rate limits
Pivotal applies a sliding-window rate limit per API key. The default is **60 requests per minute** per live key. Test keys get **30/min**. Workspaces on a paid plan get **600/min** per key — open a ticket if you need more.
There is no global per-workspace cap on top of the per-key limit; create more keys (one per service is the usual pattern) if a single key is the bottleneck.
## Response headers
Every API response — whether it succeeded, errored, or got throttled — carries the current bucket state:
| Header | Meaning |
| ----------------------- | --------------------------------------------------------- |
| `X-RateLimit-Limit` | Requests allowed in the current window |
| `X-RateLimit-Remaining` | Requests left in this window |
| `X-RateLimit-Reset` | Unix epoch seconds when the window resets |
| `Retry-After` | (429 only) seconds the client should wait before retrying |
Example:
```http
HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1748275869
Content-Type: application/json
```
## 429 responses
When the bucket is empty, Pivotal returns `429 Too Many Requests` with the standard error envelope:
```json
{
"error": {
"type": "rate_limit_error",
"code": "rate_limited",
"message": "Rate limit exceeded. Retry after 12 seconds."
}
}
```
`Retry-After` tells you exactly how long to wait. Don't parse the message — use the header.
## Handling 429 in client code
Exponential backoff with jitter is the right shape. Pseudocode:
```typescript
async function call(req: Request, attempt = 0): Promise {
const res = await fetch(req);
if (res.status !== 429 || attempt >= 5) return res;
const retryAfter = Number(res.headers.get("Retry-After") ?? 1);
const jitter = Math.random() * 0.5; // 0–500 ms
await sleep((retryAfter + jitter) * 1000);
return call(req, attempt + 1);
}
```
Two refinements worth adding:
1. **Honor `X-RateLimit-Remaining`** — if it's at 1 or 2, slow yourself down before you hit zero.
2. **Cap the retry depth** — five tries is enough; beyond that something else is wrong and the call should bubble up.
## Bulk operations
The API does not yet expose bulk endpoints. If you're moving thousands of records, see [Bulk operations](/api/guides/bulk-operations) for the recommended concurrency settings — running 4–8 in-flight requests against a 60/min bucket plus respecting `X-RateLimit-Remaining` is usually enough.
## What does NOT count
* 401 from a missing or revoked key — rejected before the rate limiter sees it.
* The token-refresh dance for any SDK — there is no token refresh; Bearer is the whole story.
## What DOES count
* Successful 2xx responses.
* 4xx responses (validation, not found, conflict) — they still spent a request slot.
* 5xx responses — same. The rate limiter doesn't know the request "shouldn't" have failed.
This means a buggy client looping on a 404 will burn its quota fast. Add a circuit breaker around any code that fails twice in a row against the same resource.
# Errors
Every non-2xx response from the Pivotal API lands in the same envelope:
```json
{
"error": {
"type": "invalid_request_error",
"code": "missing_field",
"message": "name is required",
"field": "name"
}
}
```
* **`type`** is one of seven values (table below). Branch on this in your client.
* **`code`** is a stable machine-readable string. Safe to use in switch/case logic.
* **`message`** is a sentence intended for a human — log it, surface it in admin tools, but don't string-match against it.
* **`field`** is set only when the error points at a specific request property (validation, unique-violation).
## Error types
| Type | HTTP | What it means |
| ----------------------- | ---- | ------------------------------------------------------------------------------- |
| `invalid_request_error` | 400 | The request didn't validate. Look at `code` and `field`. |
| `authentication_error` | 401 | API key missing, malformed, or revoked. |
| `permission_error` | 403 | Key is valid but lacks permission for this resource. (Not used yet — reserved.) |
| `not_found` | 404 | The resource doesn't exist, or belongs to another workspace. |
| `conflict` | 409 | Unique-constraint violation (e.g. `slug_taken`, `email_taken`). |
| `rate_limit_error` | 429 | You hit the per-key bucket. See [Rate limits](/welcome/rate-limits). |
| `internal_error` | 500 | Pivotal blew up. Retry with backoff; tell us if it persists. |
## Common codes
The set isn't huge. These show up most:
| Code | Where | Meaning |
| ---------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `missing_field` | 400 | A required field wasn't sent. `field` tells you which. |
| `invalid_field` | 400 | A field is present but failed validation (wrong type, bad format, enum miss). |
| `invalid_request` | 400 | The body shape is off (malformed JSON, wrong top-level shape). |
| `missing_api_key` | 401 | No `Authorization` header. |
| `invalid_api_key` | 401 | Header present but the key isn't recognized. |
| `api_key_revoked` | 401 | Key existed but was revoked. |
| `customer_not_found` | 404 | The customer id (display\_id or cuid) doesn't resolve. |
| `contact_not_found` | 404 | Same shape for contacts. |
| `onboarding_not_found` | 404 | Same shape for onboardings. |
| `slug_taken` | 409 | Another customer in this workspace already uses that slug. |
| `shopify_domain_taken` | 409 | Another customer in this workspace already claims that domain. The error code references the underlying column name; the `field` on this error is `shopify_domain`. |
| `email_taken` | 409 | Another contact under the same customer already uses that email. |
| `rate_limited` | 429 | Bucket empty. Honor `Retry-After`. |
| `internal_error` | 500 | Generic 500. Retry with backoff. |
| `http_exception` | 4xx | Catch-all for unexpected validation failures. Rare. |
## How to branch in client code
Match on `type` for control flow, then on `code` if you need precision:
```typescript
const res = await fetch(url, { headers, body });
if (!res.ok) {
const { error } = await res.json();
switch (error.type) {
case "invalid_request_error":
throw new ValidationError(error.field, error.message);
case "authentication_error":
throw new AuthError(error.code);
case "conflict":
// e.g. retry with a different slug
if (error.code === "slug_taken") return suggestNewSlug();
throw new ConflictError(error.message);
case "rate_limit_error": {
const wait = Number(res.headers.get("Retry-After") ?? 1);
await sleep(wait * 1000);
return retry();
}
case "not_found":
return null;
default:
throw new ApiError(error);
}
}
```
## What you won't see
* HTML error pages — every error route returns JSON.
* Bare `{ "message": "..." }` — always wrapped in `{ "error": {...} }`.
* Status codes that disagree with the `type` — `404` always pairs with `not_found`, `409` with `conflict`, etc. If you see them out of sync, that's a bug — open a ticket.
## Validation error details
Zod validation errors carry their own `code` (the Zod issue code, e.g. `invalid_type`, `too_small`) and the `field` path. The `message` is taken straight from Zod — readable, but treat it as English copy, not a parseable format.
Example: posting a customer without `name`:
```json
{
"error": {
"type": "invalid_request_error",
"code": "invalid_type",
"message": "Required",
"field": "name"
}
}
```
Posting a non-existent enum value to `status`:
```json
{
"error": {
"type": "invalid_request_error",
"code": "invalid_enum_value",
"message": "Invalid enum value. Expected 'active' | 'onboarding' | 'at_risk' | 'churned' | 'low_touch', received 'pending'",
"field": "status"
}
}
```
## Reporting issues
If you hit a 5xx more than once on a single request, capture the response headers (especially `X-Request-Id` once we ship it) and send them to [help@pivotal.app](mailto:help@pivotal.app). Include the request body if it doesn't contain secrets.
# Pagination
Every list endpoint in the Pivotal API uses cursor pagination. The pattern is the same shape across customers, contacts, and onboardings:
```http
GET /api/v1/customers?limit=20&cursor=2026-05-20T14:08:11.000Z
```
Response:
```json
{
"object": "list",
"data": [ /* 0–20 items */ ],
"has_more": true,
"next_cursor": "2026-05-19T22:41:03.000Z"
}
```
Pass `response.next_cursor` straight back as the next request's `cursor`. Stop when `has_more` is `false`.
## Parameters
| Param | Type | Default | Max | Notes |
| -------- | ----------------- | ------- | --- | -------------------------------------------------------------- |
| `limit` | integer | 20 | 100 | Page size. Larger = fewer round trips, more work per response. |
| `cursor` | ISO 8601 datetime | none | — | Returns items strictly older than this `created_at`. |
`cursor` must validate as an ISO 8601 datetime. Garbage in returns a 400:
```json
{
"error": {
"type": "invalid_request_error",
"code": "invalid_string",
"message": "Invalid datetime",
"field": "cursor"
}
}
```
## Walking a list end to end
```typescript
async function* walkCustomers() {
let cursor: string | undefined;
while (true) {
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) yield c;
if (!page.has_more) return;
cursor = page.next_cursor;
}
}
for await (const customer of walkCustomers()) {
console.log(customer.display_id, customer.name);
}
```
Python:
```python
def walk_customers():
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
res = requests.get(
"https://my.pivotal.app/api/v1/customers",
params=params,
headers={"Authorization": f"Bearer {os.environ['PIVOTAL_API_KEY']}"},
)
page = res.json()
for c in page["data"]:
yield c
if not page["has_more"]:
return
cursor = page["next_cursor"]
```
## Filters and pagination interact
Filters like `?status=active` apply on the server, then pagination cuts the filtered result. The cursor still walks `created_at` descending — you don't get drift from cursoring through a moving filter as long as the filter is stable for the duration of the walk.
If you mutate records mid-walk (e.g. flipping `status` from `onboarding` to `active` while paginating `?status=onboarding`), expect to either skip or duplicate the affected rows depending on the timing. Snapshot in your client if that's a problem.
## What we deliberately don't do
* **No total count.** Counting at scale gets expensive. If you need a UI element that says "245 customers", fetch the count separately (a future `/customers/count` endpoint will land — for now, walk the list once and cache).
* **No offset/limit.** Offsets get slower as the offset grows and behave badly with concurrent inserts. Cursors are stable and constant-cost.
* **No `before` cursor.** Lists return newest first; if you need older-first, reverse the page in your client.
## Edge cases
* An **empty page** comes back as `{ object: "list", data: [], has_more: false, next_cursor: null }`.
* The **last page** has `has_more: false` even if it returned `limit` items — trust the flag, not the count.
* **Soft-deleted rows never appear** in lists. They're still gettable by id if you know it (`GET /customers/{id}` will 404 — soft-deleted is treated as gone). If you need to read deletes, surface them through the customer's timeline.
# Versioning
The Pivotal API is on **v1**. The version lives in the URL: `https://my.pivotal.app/api/v1/…`. There is no `X-API-Version` header to set, no date pinning, and no per-key version selector.
## What we promise inside v1
Pivotal will not break your integration mid-version. Specifically, within `v1`:
* We will **not** remove an endpoint.
* We will **not** remove a field from a response.
* We will **not** rename a field, an enum value, or an `operationId`.
* We will **not** narrow a field's type (e.g. flip `string | null` to `string`).
* We will **not** add a required request field to an existing endpoint.
* We will **not** change HTTP status codes for documented outcomes.
* We will **not** change error `type` or `code` for documented errors.
## What we WILL change inside v1
Additive changes ship without a version bump:
* **New endpoints** (e.g. a future `/customers/count`).
* **New optional response fields** — design your parsers to ignore unknown keys.
* **New optional request fields** — your existing requests keep working.
* **New enum values** — design your code to fall back gracefully on unknown values (don't `switch` exhaustively without a default).
* **New error `code` values inside an existing `type`** — branch on `type` for control flow, `code` only for fine-grained recovery.
* **Tighter validation that brings a previously-loose endpoint in line with the spec.** We try to avoid this. When we do it, we'll announce in the changelog and stage it (warn first, reject later).
If a change feels like it might surprise you, we'll ship it behind a feature flag on the workspace first, then announce.
## When v2 ships
A new major version (`v2`) ships only for changes that can't be made additively:
* Renaming a resource or top-level field shape.
* Restructuring the error envelope.
* Changing auth (we don't anticipate this).
When v2 ships:
* `v1` keeps working for **at least 12 months** alongside `v2` — no overnight cutover.
* The deprecation window will be announced on the changelog and via in-app banner.
* This documentation site will surface both versions in the version switcher in the navbar.
## Detecting deprecation
When an endpoint or field is marked for deprecation:
* The OpenAPI spec adds `"deprecated": true`.
* Responses include a `Deprecation` header (date the field/endpoint goes away).
* The Pivotal admin console shows a banner on the API Keys page surfacing recent calls to deprecated endpoints.
## Changelog
The Pivotal changelog lives in this docs site (linked from the navbar once we ship our first post-launch change). Until then, the closest thing to a changelog is the `/api/v1/health` response — it reports the deployed version of the API surface.
## Reporting compatibility breaks
If something you wrote against v1 stops working without an announcement, that's a bug on our side. Send the request/response pair to [help@pivotal.app](mailto:help@pivotal.app) — we treat unannounced breakage as a P0.
# Idempotency
Pivotal's `POST` endpoints accept an `Idempotency-Key` header. Send the same key with the same body twice and you get the same response twice — the second request doesn't create a duplicate resource.
This matters because networks fail. If a `POST /customers` request times out, you can't tell whether the customer was created or not. With an idempotency key you can retry safely.
```http
POST /api/v1/customers HTTP/1.1
Authorization: Bearer pivotal_…
Content-Type: application/json
Idempotency-Key: 9c4d…-aurora-2026-05-26
{ "name": "Aurora Outfitters", "slug": "aurora", "status": "onboarding" }
```
## Key rules
* **Format:** any opaque string up to 255 bytes. UUIDs work; so do app-specific strings like `customer-create-aurora-2026-05-26`. Whatever you pick has to be unique per logical operation.
* **Scope:** keys are scoped to one workspace and one endpoint. Two different workspaces can use the same key without colliding. Two different endpoints with the same key behave independently.
* **TTL:** Pivotal retains idempotency results for **24 hours**. After that, the same key is treated as fresh and will create a new resource.
* **Header is optional.** `POST` without an `Idempotency-Key` works exactly as before — at-most-once becomes at-least-once on retry.
## What we cache
When you send `Idempotency-Key` and the request succeeds, Pivotal stashes:
* The HTTP status code.
* The full response body.
* A hash of the request body.
A second request with the same key returns the cached response, byte-for-byte, with the same `id` and `display_id`.
## Body must match
If you send the same `Idempotency-Key` with a *different* body, Pivotal returns `409 Conflict`:
```json
{
"error": {
"type": "conflict",
"code": "idempotency_key_reused",
"message": "Idempotency-Key was used with a different request body."
}
}
```
This catches a common bug: the same key getting reused across two unrelated operations because someone hardcoded `Idempotency-Key: 1`.
## In-flight collision
If two requests with the same key arrive concurrently, the second one returns `409`:
```json
{
"error": {
"type": "conflict",
"code": "idempotency_key_in_flight",
"message": "Idempotency-Key request is already in flight. Retry shortly."
}
}
```
Retry after a short delay and you'll get the result of the first one (assuming it succeeded).
## When you need a key
Use idempotency keys for:
* **Customer creation** — the main one. Don't double-create from a "Sign up" button.
* **Onboarding creation** from a webhook (e.g. HubSpot closed-won) that may redeliver.
* **Contact creation** from any source that retries.
You don't need one for:
* `GET` requests — already idempotent by definition.
* `DELETE` — soft delete twice on the same record is fine; the second call returns the same body.
* `PATCH` — same effect both times unless your patch is non-idempotent (rare with PATCH).
## Picking keys
Three patterns work well, in order of preference:
1. **Caller-side UUIDv4.** Generate a fresh UUID per logical operation, store it on your side, retry with the same UUID.
2. **Natural key plus context.** `customer-create-{external-id}-{epoch-day}` is fine if you trust your `external-id`. Avoid plain `{external-id}` — you may legitimately want to recreate after a delete.
3. **Request-body hash plus timestamp bucket.** `sha256(body) + ":" + floor(now / 60s)`. Self-contained but slightly more code.
What doesn't work: a global counter (collides on cold starts), the current Unix epoch (collides under concurrency), the user's email (the same user might legitimately create multiple resources).
## Response headers
Idempotent responses carry `Idempotent-Replay: true` on the second (cached) response:
```http
HTTP/1.1 201 Created
Idempotent-Replay: true
Content-Type: application/json
```
Log that header to confirm retries are doing what you expect.
# Create your first customer
This guide pushes a complete customer record through the API in a single Node script. You end with a customer, a primary contact, and an active onboarding — visible in `https://my.pivotal.app/customers/` when the script finishes.
## Prerequisites
* A live API key (`pivotal_…`) from [Admin > API Keys](https://my.pivotal.app/admin/api-keys).
* Node 20+ or Bun. The script uses native `fetch`.
* A scratch slug — pick something that won't collide with a real customer in your workspace. We'll use `aurora-demo`.
Export the key once:
```bash
export PIVOTAL_API_KEY=pivotal_…
```
## The script
```typescript title="create-customer.ts"
const API = "https://my.pivotal.app/api/v1";
const KEY = process.env.PIVOTAL_API_KEY!;
if (!KEY) throw new Error("PIVOTAL_API_KEY is required");
const h = {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
};
async function call(path: string, init?: RequestInit): Promise {
const res = await fetch(`${API}${path}`, { ...init, headers: { ...h, ...(init?.headers ?? {}) } });
const body = await res.json();
if (!res.ok) {
console.error("API error:", body);
throw new Error(`${res.status} ${body?.error?.code ?? "unknown"}`);
}
return body as T;
}
// 1. Create the customer
const customer = await call<{ id: string; display_id: number; slug: string }>(
"/customers",
{
method: "POST",
headers: { "Idempotency-Key": "first-customer-aurora-demo" },
body: JSON.stringify({
name: "Aurora Outfitters",
slug: "aurora-demo",
domain: "aurora-demo.com",
status: "onboarding",
}),
},
);
console.log(`Customer: ${customer.display_id} (${customer.slug})`);
// 2. Attach a primary contact
const contact = await call<{ id: string; display_id: number; email: string }>(
`/customers/${customer.display_id}/contacts`,
{
method: "POST",
body: JSON.stringify({
name: "Mira Chen",
email: "mira@aurora-demo.com",
title: "Head of Ops",
is_primary: true,
labels: ["billing", "technical"],
}),
},
);
console.log(`Contact: ${contact.display_id} (${contact.email})`);
// 3. Open an onboarding
const onboarding = await call<{ id: string; display_id: number; phase: string; state: string }>(
"/onboardings",
{
method: "POST",
headers: { "Idempotency-Key": "first-customer-aurora-demo-onb" },
body: JSON.stringify({
customer_id: customer.display_id.toString(),
phase: "before_getting_started",
state: "active",
target_launch_date: "2026-07-15T00:00:00Z",
}),
},
);
console.log(`Onboarding: ${onboarding.display_id} (${onboarding.phase})`);
console.log(`\nOpen https://my.pivotal.app/customers/${customer.display_id}-${customer.slug}`);
```
Run it:
```bash
bun create-customer.ts
# or: ts-node create-customer.ts
```
## What happened
Three rows landed:
1. A **customer** in your workspace with `display_id` assigned by Pivotal — the small integer that will appear in the URL.
2. A **contact** under that customer. Because `is_primary: true`, the customer detail page surfaces her at the top of the contacts card.
3. An **onboarding** at the `before_getting_started` phase with a launch date 6+ weeks out.
The customer's activity feed now has three rows, each labelled `API: ` as the actor. The same events flow into the per-workspace audit log at [Admin > Logs](https://my.pivotal.app/admin/logs).
## Trying it again safely
Re-run the script. Because we sent `Idempotency-Key` on both creates, the second pass returns the cached responses instead of creating duplicates. Nice property to lean on inside webhook handlers and cron jobs.
Drop the `Idempotency-Key` headers and re-run — you'll get `409 slug_taken` on the customer create. That's the expected behavior. Pick a fresh slug or send a `PATCH` instead.
## Cleaning up
The `aurora-demo` customer is soft-deletable:
```bash
curl -X DELETE "https://my.pivotal.app/api/v1/customers/${DISPLAY_ID}" \
-H "Authorization: Bearer $PIVOTAL_API_KEY"
```
The associated contact and onboarding survive the delete but disappear from list views (the parent customer is gone from list views too). Reach out if you need a hard delete.
## Next
* Set up two-way sync with your CRM in [Integration sync](/api/guides/integration-sync).
* Drive an onboarding through its phases in [Onboarding phases](/api/guides/onboarding-phases).
* Build the same flow in test mode — see [Test mode](/api/guides/test-mode).
# Onboarding phases
An onboarding's `phase` represents where the engagement sits in the Pivotal workflow. The full enum, in order:
| Phase | What it means |
| ------------------------ | ------------------------------------------------------------- |
| `before_getting_started` | Contract signed, intro call scheduled, nothing's started yet. |
| `program_design` | Requirements gathering, mockups, program scoping. |
| `review` | Design review and customer sign-off on the program. |
| `program_buildout` | Implementation underway. |
| `internal_qa` | Pre-launch testing by the Pivotal team. |
| `launch` | Live. |
| `completed` | Hand-off to ongoing CS. Terminal. |
The `state` field is separate and tracks engagement health: `active`, `paused`, `at_risk`, `waiting`. A `phase` is "where we are"; a `state` is "is anything blocking".
## The phase transition flow
`PATCH /onboardings/{id}` changes one or many fields at once. Phase transitions are the most common pattern:
```bash
curl -X PATCH https://my.pivotal.app/api/v1/onboardings/89 \
-H "Authorization: Bearer $PIVOTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phase": "program_design"}'
```
Every phase change writes two records on the Pivotal side:
1. A **timeline event** on the underlying customer — surfaces on the customer page.
2. A **phase transition** row — feeds the SLA reports inside Pivotal.
The actor on both is the calling API key's name. Nothing in your code needs to do anything extra to get audit coverage.
## Driving an onboarding end to end
Here's a script that walks an onboarding through every phase, pausing briefly between steps:
```typescript title="advance-onboarding.ts"
const API = "https://my.pivotal.app/api/v1";
const KEY = process.env.PIVOTAL_API_KEY!;
const ID = process.argv[2]; // pass onboarding display_id or cuid
const phases = [
"before_getting_started",
"program_design",
"review",
"program_buildout",
"internal_qa",
"launch",
"completed",
];
async function setPhase(phase: string) {
const res = await fetch(`${API}/onboardings/${ID}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ phase }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}`);
console.log(`→ ${phase} (updated_at=${body.updated_at})`);
}
for (const phase of phases) {
await setPhase(phase);
await new Promise((r) => setTimeout(r, 1000));
}
```
Run it on a test onboarding. Refresh the customer page in Pivotal — the timeline shows each transition.
## Combining phase + state
When an onboarding stalls, set `state` to `paused`, `at_risk`, or `waiting` **without** changing the phase:
```bash
curl -X PATCH https://my.pivotal.app/api/v1/onboardings/89 \
-H "Authorization: Bearer $PIVOTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"state": "at_risk"}'
```
When it un-sticks, set state back to `active`:
```bash
curl -X PATCH https://my.pivotal.app/api/v1/onboardings/89 \
-H "Authorization: Bearer $PIVOTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"state": "active"}'
```
Separating "where we are" from "is anything wrong" keeps the phase progression clean and the SLA reporting honest.
The `waiting` state is special: combined with `waiting_on_customer: true`, it tells Pivotal a customer task is blocking, which suspends the SLA clock until the state flips back.
## What you shouldn't do
* **Don't skip phases for cosmetics.** Pivotal computes phase-duration metrics — jumping `before_getting_started` → `launch` reads as "this onboarding launched in 4 days" in dashboards.
* **Don't reuse `completed` for anything else.** It's the terminal phase. Use `state: "paused"` to retract a launch, or `DELETE` to soft-delete a wrong record.
* **Don't PATCH `phase` on a soft-deleted onboarding.** You'll get `404 onboarding_not_found`.
## Querying by phase
`GET /onboardings?phase=launch` filters by phase. Combine with `state`:
```bash
curl "https://my.pivotal.app/api/v1/onboardings?phase=launch&state=active&limit=50" \
-H "Authorization: Bearer $PIVOTAL_API_KEY"
```
Combined with cursor pagination ([Pagination](/welcome/pagination)), this is enough to build a dashboard that polls "what's in launch this week".
## Hooking into Pivotal automations
When `phase` flips to `launch`, Pivotal fires its built-in launch-day automations: a Slack DM to the assigned CSM, a launch entry in the customer calendar, and (if the workspace has it enabled) an auto-drafted launch-recap email. These run inside Pivotal regardless of whether the phase change came from the UI or the API.
If you want to suppress those — say, you're backfilling historical onboardings — make the calls with a **test key** (`pivotal_test_…`). See [Test mode](/api/guides/test-mode).
# 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 {
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](/welcome/pagination)). Until [webhooks](/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.
# display_id vs id
Pivotal resources carry two identifiers:
| Field | Type | Example | Where it shines |
| ------------ | ------------- | --------------------------- | --------------------------------------------------- |
| `id` | string (cuid) | `cl9bn00of0000g7l8gcq8c4xk` | DB foreign keys, internal storage, API integrations |
| `display_id` | integer | `245` | URLs, support tickets, conversations between humans |
Any path parameter that takes an id (`/customers/{id}`, `/contacts/{id}`, `/onboardings/{id}`, `/customers/{customerId}/contacts`) accepts **either**. Pivotal looks at the value: if it's all digits, it resolves as a `display_id`; otherwise it's treated as a cuid.
Both lookups return the same row. There's no performance difference worth mentioning either way.
## When to use display\_id
* **URLs you'll share with humans.** `pivotal.app/customers/245` is easier to read out loud than the cuid.
* **Support tickets and Slack threads.** "Customer 245 is stuck" lands better than "customer cl9bn00of0000g7l8gcq8c4xk is stuck".
* **CSV exports.** Excel handles a 3-digit ID better than a 25-character one.
* **Logs you'll eyeball.** Easier to grep `customer=245` than the full cuid.
## When to use id (cuid)
* **Foreign keys in your own DB.** `id` is guaranteed never to collide across workspaces — `display_id` 245 exists in every workspace and means different things in each.
* **API-to-API integrations.** Stable, opaque, doesn't change shape.
* **Logging across services.** A cuid is unambiguous when it lands in a search index next to data from other tenants.
## The collision risk
Two different workspaces will both have a customer with `display_id` 245. If your code stores a Pivotal id in your DB and *might* be used across workspaces (or might be used by a partner later), prefer the cuid.
If everything in your system runs against one workspace and one API key, `display_id` is fine.
## Mixed-input handlers
If you accept a Pivotal customer id from a webhook or a third party, normalize early:
```typescript
function isDisplayId(value: string): boolean {
return /^\d+$/.test(value);
}
async function loadCustomer(idLike: string) {
const res = await fetch(`https://my.pivotal.app/api/v1/customers/${idLike}`, {
headers: { Authorization: `Bearer ${process.env.PIVOTAL_API_KEY}` },
});
if (!res.ok) return null;
return res.json(); // returns { id, display_id, ... } regardless of which you passed
}
```
The response always carries **both** fields, so once you've looked up a record by `display_id` you can stash the cuid for future calls.
## What's in the URL
When Pivotal renders a customer page, the URL is `/customers/{display_id}-{slug}`. Example: `/customers/245-aurora`. The slug is decorative — Pivotal ignores it when routing, so `/customers/245-totally-wrong-slug` still loads customer 245. We include the slug for human-readability and SEO inside the workspace.
API paths don't include the slug. `/api/v1/customers/245` and `/api/v1/customers/aurora` are not the same: the second one looks up by cuid (and will 404 because `aurora` isn't a cuid).
To look up by slug, list and filter:
```bash
curl "https://my.pivotal.app/api/v1/customers?slug=aurora&limit=1" \
-H "Authorization: Bearer $PIVOTAL_API_KEY"
```
(Filter on the list endpoint, take `data[0]`.)
## Webhook payloads (when they land)
When [webhooks](/welcome/webhooks) ship, every event payload will include both `id` and `display_id` on every embedded object. Your handlers can match on either depending on what they store.
# Bulk operations
The Pivotal API doesn't expose bulk endpoints (e.g. there is no `POST /customers/bulk` that takes an array). The reason: bulk endpoints either lie about partial-failure handling, or push the complexity into a job queue you can't see into.
For now, fan out from your side. The patterns below handle 95% of bulk imports — backfills, migrations, periodic refreshes.
## Sizing the work
Two numbers matter:
* **Rate limit:** 60 requests/min on a default key, 600/min on paid. Each bulk operation is `N` requests, where `N` is your record count.
* **Worker concurrency:** how many requests you have in flight at once.
A safe default for a 60/min key: **concurrency of 4**, with each task waiting briefly between requests. That keeps you well under the bucket while staying fast enough to finish 5,000 records in \~85 minutes.
For 600/min: concurrency of 10 finishes 5,000 records in under 10 minutes.
## A bulk import script
```typescript title="bulk-import.ts"
import pLimit from "p-limit";
interface SourceCustomer { name: string; slug: string; domain: string; external_id: string }
const SOURCE: SourceCustomer[] = await loadFromCsv("./customers.csv");
const CHECKPOINT_FILE = "./bulk-import.checkpoint";
// Resume support: skip records we've already pushed.
const done = new Set(
(await Bun.file(CHECKPOINT_FILE).text().catch(() => "")).split("\n").filter(Boolean),
);
const limit = pLimit(4); // concurrency
let errors = 0;
async function importOne(c: SourceCustomer) {
if (done.has(c.external_id)) return;
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": `bulk-import-${c.external_id}`,
},
body: JSON.stringify({
name: c.name,
slug: c.slug,
domain: c.domain,
status: "onboarding",
metadata: { external_id: c.external_id },
}),
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 5);
await new Promise((r) => setTimeout(r, wait * 1000));
return importOne(c); // retry
}
const body = await res.json();
if (!res.ok && body.error?.code !== "slug_taken") {
errors += 1;
console.error(`✗ ${c.external_id}:`, body.error?.code, body.error?.message);
return;
}
done.add(c.external_id);
await Bun.write(CHECKPOINT_FILE, [...done].join("\n"));
console.log(`✓ ${c.external_id} → ${body.id}`);
}
await Promise.all(SOURCE.map((c) => limit(() => importOne(c))));
console.log(`Done. ${done.size} succeeded, ${errors} failed.`);
```
Three things to call out:
1. **`Idempotency-Key`** lets you re-run after a crash without duplicating records.
2. **The checkpoint file** is a flat-text list of `external_id`s. Crude but enough for a one-shot script. For long-running services, use Redis or a Postgres row.
3. **`slug_taken` is accepted** as a non-error — it means the customer was created in a previous run.
## Why per-record `Idempotency-Key`
If you send the same body to `POST /customers` twice without a key, you get `409 slug_taken` on the second attempt. With a key, you get `200 OK` with the original response — which lets the script keep moving and treat it as "already done".
## Throttling against `X-RateLimit-Remaining`
The script above retries on 429. You can do better — slow down before you hit zero:
```typescript
async function importOne(c: SourceCustomer) {
const res = await fetch(/* ... */);
const remaining = Number(res.headers.get("X-RateLimit-Remaining") ?? 100);
if (remaining < 5) await new Promise((r) => setTimeout(r, 1000));
// ... rest of the handler
}
```
This drops your peak throughput slightly but eliminates 429 round trips entirely.
## What to do about contacts and onboardings
Bulk-importing customers is the main case. If you also need to attach contacts and onboardings, fan them out the same way **after** the customer pass finishes:
```typescript
// Pass 1: customers
await Promise.all(SOURCE.map((c) => limit(() => importCustomer(c))));
// Pass 2: contacts (now we have Pivotal customer ids)
const idMap = await loadCheckpoint("./bulk-import.checkpoint.contacts");
await Promise.all(SOURCE.flatMap((c) =>
c.contacts.map((contact) => limit(() => importContact(c.external_id, contact))),
));
// Pass 3: onboardings (last — they read back the customer state)
await Promise.all(SOURCE.map((c) => limit(() => importOnboarding(c))));
```
Three passes is more wall time but easier to reason about than interleaving. The total request count is the same.
## Reporting
After the script finishes, eyeball:
* **Checkpoint count** vs source count — should match.
* **Errors** — anything that isn't `slug_taken`, look at the error and decide.
* **The Pivotal API Keys page** — confirms the request count tracks what your script logged.
## What NOT to do
* **Don't disable retries.** Network blips are real. The script above retries automatically on 429; add retries on transient 5xx too.
* **Don't share keys across bulk jobs.** One key per bulk run makes the API Keys page a useful audit trail.
* **Don't fan out without a concurrency limit.** `Promise.all` over 5,000 records means 5,000 in-flight requests. You'll trip the rate limit and the OS file descriptor limit before you trip anything else.
* **Don't import then mutate the same row in the same pass.** Two requests against the same record concurrently is a race. Let pass 1 finish before mutating.
# Test mode
A test key (`pivotal_test_…`) hits the same endpoints, against the same database, as a live key. The difference: **outbound integration side effects are suppressed**. Slack DMs don't go out. Resend emails don't send. Stripe webhook acks don't fire. The Pivotal app records the action and the audit log; no human gets paged.
This makes test keys useful for:
* **Local development** — run your integration against your real workspace without spamming the team Slack.
* **CI smoke tests** — verify the integration end-to-end on every push without polluting customer-facing channels.
* **Migration dry runs** — script a backfill in test mode, eyeball the result inside Pivotal, then re-run with a live key.
## What test mode does NOT do
* **It does NOT create a sandbox copy of your workspace.** Records created by a test key sit in your live database alongside everything else. Deleting them means calling `DELETE` (soft delete) or filtering them out by setting `metadata.test: true` on creation.
* **It does NOT exempt you from rate limits.** Test keys have a *separate* bucket (30/min by default), but they're still rate-limited.
* **It does NOT bypass validation.** Bad payloads still get 4xx.
* **It does NOT prevent UI changes.** Customers, contacts, and onboardings created by a test key show up in the regular UI.
If you need a fully isolated environment, create a second workspace (the Pivotal app supports multiple orgs per Clerk user) and create live keys against that.
## Spotting test-mode behavior
The `/me` endpoint reports the mode:
```json
{
"object": "api_key_identity",
"key_name": "ci-smoke",
"key_prefix": "pivotal_test_def4",
"org_id": "org_2pXh…",
"org_slug": "vml",
"mode": "test"
}
```
The audit log at [Admin > Logs](https://my.pivotal.app/admin/logs) tags each request with the mode. Filter by `mode=test` to see only test-key activity. The activity feed on a customer page surfaces the same thing.
## A pattern for CI smoke tests
In CI, set up a test key once and store it as a repo secret. The smoke test:
1. Creates a throwaway customer with `metadata.ci_run_id = `.
2. Attaches a contact.
3. Opens an onboarding, advances it through one phase change.
4. Soft-deletes everything it created.
```typescript title=".github/scripts/smoke.ts"
const tag = process.env.GITHUB_SHA?.slice(0, 7) ?? `local-${Date.now()}`;
const slug = `ci-${tag}`;
const customer = await create("/customers", {
name: `CI ${tag}`,
slug,
status: "onboarding",
metadata: { ci_run_id: tag, test: true },
});
const contact = await create(`/customers/${customer.display_id}/contacts`, {
name: "CI Bot",
email: `ci+${tag}@example.com`,
is_primary: true,
});
const onb = await create("/onboardings", {
customer_id: customer.display_id.toString(),
phase: "before_getting_started",
state: "active",
});
await patch(`/onboardings/${onb.display_id}`, { phase: "program_design" });
// teardown
await del(`/onboardings/${onb.display_id}`);
await del(`/contacts/${contact.display_id}`);
await del(`/customers/${customer.display_id}`);
```
The teardown step is what keeps your workspace tidy. Skip it and you accumulate one stale customer per CI run.
## Tagging records
We strongly suggest tagging every test-mode record with `metadata.test: true`. Reasons:
* Filtering the customer list in the UI is straightforward (admins can collapse `test=true` rows).
* A separate "cleanup all test-mode records" admin action can be written against the API in a single pass.
* It makes the difference visible to humans who didn't write the integration.
Pivotal doesn't enforce this — it's a convention.
## What if I want a fully separate workspace?
Two options:
* **Personal sandbox workspace.** Create a second Clerk org from inside the app. Create live keys against it. Your real workspace's data is untouched.
* **Whitelabel sandbox.** Pivotal can spin up a dedicated demo workspace for partners and integrators. Email [help@pivotal.app](mailto:help@pivotal.app) — useful when you're building an integration to distribute and want a clean staging env.
## Promoting test code to live
When the test-mode integration is working, the **only** change to flip to live is the API key. Same endpoints, same payloads, same response shapes. Many teams keep both keys configured and toggle based on an env var:
```typescript
const KEY = process.env.NODE_ENV === "production"
? process.env.PIVOTAL_API_KEY!
: process.env.PIVOTAL_API_KEY_TEST!;
```
Once you flip live, monitor `/me` on startup to confirm the mode — easy way to catch a misconfigured env.
# Get current identity
GET https://my.pivotal.app/api/v1/me
Returns the API key's identity, organization, scopes, and rate-limit tier. Hit this first to confirm a newly-created key is wired up correctly.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/meta/get-me
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/me:
get:
operationId: get-me
summary: Get current identity
description: >-
Returns the API key's identity, organization, scopes, and rate-limit
tier. Hit this first to confirm a newly-created key is wired up
correctly.
tags:
- subpackage_meta
parameters:
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Me'
servers:
- url: https://my.pivotal.app
components:
schemas:
MeObject:
type: string
enum:
- me
title: MeObject
MeApiKey:
type: object
properties:
id:
type: string
name:
type: string
prefix:
type: string
scopes:
type: array
items:
type: string
rate_limit_tier:
type: string
required:
- id
- name
- prefix
- scopes
- rate_limit_tier
title: MeApiKey
MeOrganization:
type: object
properties:
id:
type: string
slug:
type: string
name:
type: string
required:
- id
- slug
- name
title: MeOrganization
Me:
type: object
properties:
object:
$ref: '#/components/schemas/MeObject'
api_key:
$ref: '#/components/schemas/MeApiKey'
organization:
$ref: '#/components/schemas/MeOrganization'
required:
- object
- api_key
- organization
title: Me
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/me"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/me';
const options = {
method: 'GET',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/me"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/me")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/me")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/me', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/me");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/me")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Liveness probe
GET https://my.pivotal.app/api/v1/health
Unauthenticated. Returns 200 if the process is up. Use this for uptime monitoring; for a deeper integration probe see /api/health/deep on the canonical host.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/meta/get-health
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/health:
get:
operationId: get-health
summary: Liveness probe
description: >-
Unauthenticated. Returns 200 if the process is up. Use this for uptime
monitoring; for a deeper integration probe see /api/health/deep on the
canonical host.
tags:
- subpackage_meta
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Health'
servers:
- url: https://my.pivotal.app
components:
schemas:
HealthStatus:
type: string
enum:
- ok
title: HealthStatus
Health:
type: object
properties:
status:
$ref: '#/components/schemas/HealthStatus'
timestamp:
type: string
required:
- status
- timestamp
title: Health
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/health"
payload = {}
headers = {"Content-Type": "application/json"}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/health';
const options = {method: 'GET', headers: {'Content-Type': 'application/json'}, body: '{}'};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/health"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/health")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/health")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/health', [
'body' => '{}',
'headers' => [
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/health");
var request = new RestRequest(Method.GET);
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = ["Content-Type": "application/json"]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/health")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# List customers
GET https://my.pivotal.app/api/v1/customers
Returns customers in the calling org, newest first. Cursor-paginated — pass `next_cursor` from a previous response back as `cursor` to get the next page.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/customers/list-customers
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/customers:
get:
operationId: list-customers
summary: List customers
description: >-
Returns customers in the calling org, newest first. Cursor-paginated —
pass `next_cursor` from a previous response back as `cursor` to get the
next page.
tags:
- subpackage_customers
parameters:
- name: limit
in: query
description: 1–100, default 25.
required: false
schema:
type: string
- name: cursor
in: query
description: Pagination cursor from a previous response (ISO timestamp).
required: false
schema:
type: string
format: date-time
- name: status
in: query
required: false
schema:
$ref: '#/components/schemas/ApiV1CustomersGetParametersStatus'
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerList'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: Rate limit exceeded
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
ApiV1CustomersGetParametersStatus:
type: string
enum:
- active
- onboarding
- at_risk
- churned
- low_touch
title: ApiV1CustomersGetParametersStatus
CustomerListObject:
type: string
enum:
- list
title: CustomerListObject
CustomerObject:
type: string
enum:
- customer
title: CustomerObject
Customer:
type: object
properties:
object:
$ref: '#/components/schemas/CustomerObject'
id:
type: string
display_id:
type: integer
name:
type: string
slug:
type:
- string
- 'null'
domain:
type:
- string
- 'null'
status:
type: string
plan:
type:
- string
- 'null'
mrr:
type:
- integer
- 'null'
description: Monthly recurring revenue in cents.
monthly_orders:
type:
- integer
- 'null'
hubspot_company_id:
type:
- string
- 'null'
hubspot_deal_id:
type:
- string
- 'null'
stripe_customer_id:
type:
- string
- 'null'
intercom_company_id:
type:
- string
- 'null'
slack_channel_id:
type:
- string
- 'null'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- name
- slug
- domain
- status
- plan
- mrr
- monthly_orders
- hubspot_company_id
- hubspot_deal_id
- stripe_customer_id
- intercom_company_id
- slack_channel_id
- created_at
- updated_at
title: Customer
CustomerList:
type: object
properties:
object:
$ref: '#/components/schemas/CustomerListObject'
data:
type: array
items:
$ref: '#/components/schemas/Customer'
has_more:
type: boolean
next_cursor:
type:
- string
- 'null'
required:
- object
- data
- has_more
- next_cursor
title: CustomerList
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/customers"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/customers';
const options = {
method: 'GET',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/customers"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/customers")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/customers")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/customers', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/customers");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/customers")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Create a customer
POST https://my.pivotal.app/api/v1/customers
Content-Type: application/json
Adds a customer to the calling org. `name` is the only required field; `slug` must be unique within the org and shows up in human-facing URLs.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/customers/create-customer
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/customers:
post:
operationId: create-customer
summary: Create a customer
description: >-
Adds a customer to the calling org. `name` is the only required field;
`slug` must be unique within the org and shows up in human-facing URLs.
tags:
- subpackage_customers
parameters:
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/Customer'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Conflict (duplicate slug)
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerCreate'
servers:
- url: https://my.pivotal.app
components:
schemas:
CustomerCreateStatus:
type: string
enum:
- active
- onboarding
- at_risk
- churned
- low_touch
title: CustomerCreateStatus
CustomerCreate:
type: object
properties:
name:
type: string
slug:
type: string
domain:
type: string
status:
$ref: '#/components/schemas/CustomerCreateStatus'
plan:
type: string
mrr:
type: integer
monthly_orders:
type: integer
hubspot_company_id:
type: string
hubspot_deal_id:
type: string
stripe_customer_id:
type: string
intercom_company_id:
type: string
slack_channel_id:
type: string
required:
- name
title: CustomerCreate
CustomerObject:
type: string
enum:
- customer
title: CustomerObject
Customer:
type: object
properties:
object:
$ref: '#/components/schemas/CustomerObject'
id:
type: string
display_id:
type: integer
name:
type: string
slug:
type:
- string
- 'null'
domain:
type:
- string
- 'null'
status:
type: string
plan:
type:
- string
- 'null'
mrr:
type:
- integer
- 'null'
description: Monthly recurring revenue in cents.
monthly_orders:
type:
- integer
- 'null'
hubspot_company_id:
type:
- string
- 'null'
hubspot_deal_id:
type:
- string
- 'null'
stripe_customer_id:
type:
- string
- 'null'
intercom_company_id:
type:
- string
- 'null'
slack_channel_id:
type:
- string
- 'null'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- name
- slug
- domain
- status
- plan
- mrr
- monthly_orders
- hubspot_company_id
- hubspot_deal_id
- stripe_customer_id
- intercom_company_id
- slack_channel_id
- created_at
- updated_at
title: Customer
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/customers"
payload = { "name": "Bright Future Tech" }
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/customers';
const options = {
method: 'POST',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{"name":"Bright Future Tech"}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/customers"
payload := strings.NewReader("{\n \"name\": \"Bright Future Tech\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/customers")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{\n \"name\": \"Bright Future Tech\"\n}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.post("https://my.pivotal.app/api/v1/customers")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{\n \"name\": \"Bright Future Tech\"\n}")
.asString();
```
```php
request('POST', 'https://my.pivotal.app/api/v1/customers', [
'body' => '{
"name": "Bright Future Tech"
}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/customers");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n \"name\": \"Bright Future Tech\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = ["name": "Bright Future Tech"] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/customers")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Retrieve a customer
GET https://my.pivotal.app/api/v1/customers/{id}
Fetch one customer by numeric `display_id` (shareable, appears in URLs) or canonical cuid `id`.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/customers/get-customer
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/customers/{id}:
get:
operationId: get-customer
summary: Retrieve a customer
description: >-
Fetch one customer by numeric `display_id` (shareable, appears in URLs)
or canonical cuid `id`.
tags:
- subpackage_customers
parameters:
- name: id
in: path
description: Numeric display_id or canonical cuid id.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Customer'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Customer not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
CustomerObject:
type: string
enum:
- customer
title: CustomerObject
Customer:
type: object
properties:
object:
$ref: '#/components/schemas/CustomerObject'
id:
type: string
display_id:
type: integer
name:
type: string
slug:
type:
- string
- 'null'
domain:
type:
- string
- 'null'
status:
type: string
plan:
type:
- string
- 'null'
mrr:
type:
- integer
- 'null'
description: Monthly recurring revenue in cents.
monthly_orders:
type:
- integer
- 'null'
hubspot_company_id:
type:
- string
- 'null'
hubspot_deal_id:
type:
- string
- 'null'
stripe_customer_id:
type:
- string
- 'null'
intercom_company_id:
type:
- string
- 'null'
slack_channel_id:
type:
- string
- 'null'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- name
- slug
- domain
- status
- plan
- mrr
- monthly_orders
- hubspot_company_id
- hubspot_deal_id
- stripe_customer_id
- intercom_company_id
- slack_channel_id
- created_at
- updated_at
title: Customer
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/customers/42"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/customers/42';
const options = {
method: 'GET',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/customers/42"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/customers/42")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/customers/42")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/customers/42', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/customers/42");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/customers/42")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Delete a customer
DELETE https://my.pivotal.app/api/v1/customers/{id}
Soft delete — the row is hidden from reads but preserved for audit. Idempotent.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/customers/delete-customer
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/customers/{id}:
delete:
operationId: delete-customer
summary: Delete a customer
description: >-
Soft delete — the row is hidden from reads but preserved for audit.
Idempotent.
tags:
- subpackage_customers
parameters:
- name: id
in: path
description: Numeric display_id or canonical cuid id.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Deleted'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Customer not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
DeletedObject:
type: string
enum:
- deleted
title: DeletedObject
Deleted:
type: object
properties:
object:
$ref: '#/components/schemas/DeletedObject'
id:
type: string
display_id:
type: integer
deleted:
type: boolean
required:
- object
- id
- display_id
- deleted
title: Deleted
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/customers/42"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.delete(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/customers/42';
const options = {
method: 'DELETE',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/customers/42"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("DELETE", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/customers/42")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Delete.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.delete("https://my.pivotal.app/api/v1/customers/42")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('DELETE', 'https://my.pivotal.app/api/v1/customers/42', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/customers/42");
var request = new RestRequest(Method.DELETE);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/customers/42")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "DELETE"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Update a customer
PATCH https://my.pivotal.app/api/v1/customers/{id}
Content-Type: application/json
Partial update. Send only the fields you want to change.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/customers/update-customer
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/customers/{id}:
patch:
operationId: update-customer
summary: Update a customer
description: Partial update. Send only the fields you want to change.
tags:
- subpackage_customers
parameters:
- name: id
in: path
description: Numeric display_id or canonical cuid id.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Customer'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Customer not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Conflict (duplicate slug)
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerUpdate'
servers:
- url: https://my.pivotal.app
components:
schemas:
CustomerUpdateStatus:
type: string
enum:
- active
- onboarding
- at_risk
- churned
- low_touch
title: CustomerUpdateStatus
CustomerUpdate:
type: object
properties:
name:
type: string
slug:
type: string
domain:
type: string
status:
$ref: '#/components/schemas/CustomerUpdateStatus'
plan:
type: string
mrr:
type: integer
monthly_orders:
type: integer
hubspot_company_id:
type: string
hubspot_deal_id:
type: string
stripe_customer_id:
type: string
intercom_company_id:
type: string
slack_channel_id:
type: string
title: CustomerUpdate
CustomerObject:
type: string
enum:
- customer
title: CustomerObject
Customer:
type: object
properties:
object:
$ref: '#/components/schemas/CustomerObject'
id:
type: string
display_id:
type: integer
name:
type: string
slug:
type:
- string
- 'null'
domain:
type:
- string
- 'null'
status:
type: string
plan:
type:
- string
- 'null'
mrr:
type:
- integer
- 'null'
description: Monthly recurring revenue in cents.
monthly_orders:
type:
- integer
- 'null'
hubspot_company_id:
type:
- string
- 'null'
hubspot_deal_id:
type:
- string
- 'null'
stripe_customer_id:
type:
- string
- 'null'
intercom_company_id:
type:
- string
- 'null'
slack_channel_id:
type:
- string
- 'null'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- name
- slug
- domain
- status
- plan
- mrr
- monthly_orders
- hubspot_company_id
- hubspot_deal_id
- stripe_customer_id
- intercom_company_id
- slack_channel_id
- created_at
- updated_at
title: Customer
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/customers/42"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.patch(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/customers/42';
const options = {
method: 'PATCH',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/customers/42"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("PATCH", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/customers/42")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Patch.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.patch("https://my.pivotal.app/api/v1/customers/42")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('PATCH', 'https://my.pivotal.app/api/v1/customers/42', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/customers/42");
var request = new RestRequest(Method.PATCH);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/customers/42")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "PATCH"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# List a customer's contacts
GET https://my.pivotal.app/api/v1/customers/{customerId}/contacts
Primary contact first, then most recently created.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/contacts/list-customer-contacts
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/customers/{customerId}/contacts:
get:
operationId: list-customer-contacts
summary: List a customer's contacts
description: Primary contact first, then most recently created.
tags:
- subpackage_contacts
parameters:
- name: customerId
in: path
description: Numeric display_id or cuid of the parent customer.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ContactList'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Customer not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
ContactListObject:
type: string
enum:
- list
title: ContactListObject
ContactObject:
type: string
enum:
- contact
title: ContactObject
Contact:
type: object
properties:
object:
$ref: '#/components/schemas/ContactObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
name:
type: string
email:
type: string
format: email
title:
type:
- string
- 'null'
is_primary:
type: boolean
labels:
type: array
items:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- name
- email
- title
- is_primary
- labels
- created_at
- updated_at
title: Contact
ContactList:
type: object
properties:
object:
$ref: '#/components/schemas/ContactListObject'
data:
type: array
items:
$ref: '#/components/schemas/Contact'
required:
- object
- data
title: ContactList
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/customers/42/contacts"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/customers/42/contacts';
const options = {
method: 'GET',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/customers/42/contacts"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/customers/42/contacts")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/customers/42/contacts")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/customers/42/contacts', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/customers/42/contacts");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/customers/42/contacts")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Add a contact to a customer
POST https://my.pivotal.app/api/v1/customers/{customerId}/contacts
Content-Type: application/json
Email is lowercased on the way in and unique per customer.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/contacts/create-customer-contact
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/customers/{customerId}/contacts:
post:
operationId: create-customer-contact
summary: Add a contact to a customer
description: Email is lowercased on the way in and unique per customer.
tags:
- subpackage_contacts
parameters:
- name: customerId
in: path
description: Numeric display_id or cuid of the parent customer.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/Contact'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Customer not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Email already exists for this customer
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ContactCreate'
servers:
- url: https://my.pivotal.app
components:
schemas:
ContactCreate:
type: object
properties:
name:
type: string
email:
type: string
format: email
title:
type: string
is_primary:
type: boolean
labels:
type: array
items:
type: string
required:
- name
- email
title: ContactCreate
ContactObject:
type: string
enum:
- contact
title: ContactObject
Contact:
type: object
properties:
object:
$ref: '#/components/schemas/ContactObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
name:
type: string
email:
type: string
format: email
title:
type:
- string
- 'null'
is_primary:
type: boolean
labels:
type: array
items:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- name
- email
- title
- is_primary
- labels
- created_at
- updated_at
title: Contact
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/customers/42/contacts"
payload = {
"name": "Alice Johnson",
"email": "alice.johnson@example.com"
}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/customers/42/contacts';
const options = {
method: 'POST',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{"name":"Alice Johnson","email":"alice.johnson@example.com"}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/customers/42/contacts"
payload := strings.NewReader("{\n \"name\": \"Alice Johnson\",\n \"email\": \"alice.johnson@example.com\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/customers/42/contacts")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{\n \"name\": \"Alice Johnson\",\n \"email\": \"alice.johnson@example.com\"\n}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.post("https://my.pivotal.app/api/v1/customers/42/contacts")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{\n \"name\": \"Alice Johnson\",\n \"email\": \"alice.johnson@example.com\"\n}")
.asString();
```
```php
request('POST', 'https://my.pivotal.app/api/v1/customers/42/contacts', [
'body' => '{
"name": "Alice Johnson",
"email": "alice.johnson@example.com"
}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/customers/42/contacts");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n \"name\": \"Alice Johnson\",\n \"email\": \"alice.johnson@example.com\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [
"name": "Alice Johnson",
"email": "alice.johnson@example.com"
] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/customers/42/contacts")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Retrieve a contact
GET https://my.pivotal.app/api/v1/contacts/{id}
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/contacts/get-contact
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/contacts/{id}:
get:
operationId: get-contact
summary: Retrieve a contact
tags:
- subpackage_contacts
parameters:
- name: id
in: path
description: Numeric display_id or cuid of the contact.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Contact'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Contact not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
ContactObject:
type: string
enum:
- contact
title: ContactObject
Contact:
type: object
properties:
object:
$ref: '#/components/schemas/ContactObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
name:
type: string
email:
type: string
format: email
title:
type:
- string
- 'null'
is_primary:
type: boolean
labels:
type: array
items:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- name
- email
- title
- is_primary
- labels
- created_at
- updated_at
title: Contact
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/contacts/12"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/contacts/12';
const options = {
method: 'GET',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/contacts/12"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/contacts/12")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/contacts/12")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/contacts/12', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/contacts/12");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/contacts/12")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Delete a contact
DELETE https://my.pivotal.app/api/v1/contacts/{id}
Soft delete — preserved for audit, hidden from reads.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/contacts/delete-contact
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/contacts/{id}:
delete:
operationId: delete-contact
summary: Delete a contact
description: Soft delete — preserved for audit, hidden from reads.
tags:
- subpackage_contacts
parameters:
- name: id
in: path
description: Numeric display_id or cuid of the contact.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/DeletedContact'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Contact not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
DeletedContactObject:
type: string
enum:
- deleted
title: DeletedContactObject
DeletedContact:
type: object
properties:
object:
$ref: '#/components/schemas/DeletedContactObject'
id:
type: string
display_id:
type: integer
deleted:
type: boolean
required:
- object
- id
- display_id
- deleted
title: DeletedContact
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/contacts/12"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.delete(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/contacts/12';
const options = {
method: 'DELETE',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/contacts/12"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("DELETE", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/contacts/12")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Delete.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.delete("https://my.pivotal.app/api/v1/contacts/12")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('DELETE', 'https://my.pivotal.app/api/v1/contacts/12', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/contacts/12");
var request = new RestRequest(Method.DELETE);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/contacts/12")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "DELETE"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Update a contact
PATCH https://my.pivotal.app/api/v1/contacts/{id}
Content-Type: application/json
Partial update. Send only the fields you want to change.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/contacts/update-contact
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/contacts/{id}:
patch:
operationId: update-contact
summary: Update a contact
description: Partial update. Send only the fields you want to change.
tags:
- subpackage_contacts
parameters:
- name: id
in: path
description: Numeric display_id or cuid of the contact.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Contact'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Contact not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Email already exists for this customer
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ContactUpdate'
servers:
- url: https://my.pivotal.app
components:
schemas:
ContactUpdate:
type: object
properties:
name:
type: string
email:
type: string
format: email
title:
type: string
is_primary:
type: boolean
labels:
type: array
items:
type: string
title: ContactUpdate
ContactObject:
type: string
enum:
- contact
title: ContactObject
Contact:
type: object
properties:
object:
$ref: '#/components/schemas/ContactObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
name:
type: string
email:
type: string
format: email
title:
type:
- string
- 'null'
is_primary:
type: boolean
labels:
type: array
items:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- name
- email
- title
- is_primary
- labels
- created_at
- updated_at
title: Contact
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/contacts/12"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.patch(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/contacts/12';
const options = {
method: 'PATCH',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/contacts/12"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("PATCH", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/contacts/12")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Patch.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.patch("https://my.pivotal.app/api/v1/contacts/12")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('PATCH', 'https://my.pivotal.app/api/v1/contacts/12', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/contacts/12");
var request = new RestRequest(Method.PATCH);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/contacts/12")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "PATCH"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# List onboardings
GET https://my.pivotal.app/api/v1/onboardings
Filter by `customer_id`, `state`, or `phase`. `state=waiting` is derived from `waiting_on_customer = true`. Cursor-paginated.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/onboardings/list-onboardings
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/onboardings:
get:
operationId: list-onboardings
summary: List onboardings
description: >-
Filter by `customer_id`, `state`, or `phase`. `state=waiting` is derived
from `waiting_on_customer = true`. Cursor-paginated.
tags:
- subpackage_onboardings
parameters:
- name: limit
in: query
description: 1–100, default 25.
required: false
schema:
type: string
- name: cursor
in: query
description: Pagination cursor from a previous response (ISO timestamp).
required: false
schema:
type: string
format: date-time
- name: customer_id
in: query
description: Filter by customer (display_id or cuid).
required: false
schema:
type: string
- name: state
in: query
required: false
schema:
$ref: '#/components/schemas/ApiV1OnboardingsGetParametersState'
- name: phase
in: query
required: false
schema:
$ref: '#/components/schemas/ApiV1OnboardingsGetParametersPhase'
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingList'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
ApiV1OnboardingsGetParametersState:
type: string
enum:
- active
- paused
- at_risk
- waiting
title: ApiV1OnboardingsGetParametersState
ApiV1OnboardingsGetParametersPhase:
type: string
enum:
- before_getting_started
- program_design
- review
- program_buildout
- internal_qa
- launch
- completed
title: ApiV1OnboardingsGetParametersPhase
OnboardingListObject:
type: string
enum:
- list
title: OnboardingListObject
OnboardingObject:
type: string
enum:
- onboarding
title: OnboardingObject
OnboardingPhase:
type: string
enum:
- before_getting_started
- program_design
- review
- program_buildout
- internal_qa
- launch
- completed
title: OnboardingPhase
OnboardingState:
type: string
enum:
- active
- paused
- at_risk
- waiting
title: OnboardingState
Onboarding:
type: object
properties:
object:
$ref: '#/components/schemas/OnboardingObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
template_id:
type:
- string
- 'null'
phase:
$ref: '#/components/schemas/OnboardingPhase'
state:
$ref: '#/components/schemas/OnboardingState'
started_at:
type: string
format: date-time
completed_at:
type:
- string
- 'null'
format: date-time
target_launch_date:
type:
- string
- 'null'
format: date-time
csm_id:
type:
- string
- 'null'
se_id:
type:
- string
- 'null'
designer_id:
type:
- string
- 'null'
product_ops_id:
type:
- string
- 'null'
notes:
type:
- string
- 'null'
waiting_on_customer:
type: boolean
waiting_on_customer_since:
type:
- string
- 'null'
format: date-time
wizard_completed_at:
type:
- string
- 'null'
format: date-time
reminder_email:
type: boolean
reminder_slack:
type: boolean
reminder_frequency_days:
type: integer
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- template_id
- phase
- state
- started_at
- completed_at
- target_launch_date
- csm_id
- se_id
- designer_id
- product_ops_id
- notes
- waiting_on_customer
- waiting_on_customer_since
- wizard_completed_at
- reminder_email
- reminder_slack
- reminder_frequency_days
- created_at
- updated_at
title: Onboarding
OnboardingList:
type: object
properties:
object:
$ref: '#/components/schemas/OnboardingListObject'
data:
type: array
items:
$ref: '#/components/schemas/Onboarding'
has_more:
type: boolean
next_cursor:
type:
- string
- 'null'
required:
- object
- data
- has_more
- next_cursor
title: OnboardingList
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/onboardings"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/onboardings';
const options = {
method: 'GET',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/onboardings"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/onboardings")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/onboardings")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/onboardings', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/onboardings");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/onboardings")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Create an onboarding
POST https://my.pivotal.app/api/v1/onboardings
Content-Type: application/json
Defaults: phase=before_getting_started, state=active, reminders on, frequency 1 day.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/onboardings/create-onboarding
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/onboardings:
post:
operationId: create-onboarding
summary: Create an onboarding
description: >-
Defaults: phase=before_getting_started, state=active, reminders on,
frequency 1 day.
tags:
- subpackage_onboardings
parameters:
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/Onboarding'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Customer not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingCreate'
servers:
- url: https://my.pivotal.app
components:
schemas:
OnboardingCreatePhase:
type: string
enum:
- before_getting_started
- program_design
- review
- program_buildout
- internal_qa
- launch
- completed
title: OnboardingCreatePhase
OnboardingCreateState:
type: string
enum:
- active
- paused
- at_risk
- waiting
title: OnboardingCreateState
OnboardingCreate:
type: object
properties:
customer_id:
type: string
description: Numeric display_id or cuid of the parent customer.
template_id:
type: string
phase:
$ref: '#/components/schemas/OnboardingCreatePhase'
state:
$ref: '#/components/schemas/OnboardingCreateState'
target_launch_date:
type: string
format: date-time
csm_id:
type:
- string
- 'null'
se_id:
type:
- string
- 'null'
designer_id:
type:
- string
- 'null'
product_ops_id:
type:
- string
- 'null'
notes:
type: string
reminder_email:
type: boolean
reminder_slack:
type: boolean
reminder_frequency_days:
type: integer
required:
- customer_id
title: OnboardingCreate
OnboardingObject:
type: string
enum:
- onboarding
title: OnboardingObject
OnboardingPhase:
type: string
enum:
- before_getting_started
- program_design
- review
- program_buildout
- internal_qa
- launch
- completed
title: OnboardingPhase
OnboardingState:
type: string
enum:
- active
- paused
- at_risk
- waiting
title: OnboardingState
Onboarding:
type: object
properties:
object:
$ref: '#/components/schemas/OnboardingObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
template_id:
type:
- string
- 'null'
phase:
$ref: '#/components/schemas/OnboardingPhase'
state:
$ref: '#/components/schemas/OnboardingState'
started_at:
type: string
format: date-time
completed_at:
type:
- string
- 'null'
format: date-time
target_launch_date:
type:
- string
- 'null'
format: date-time
csm_id:
type:
- string
- 'null'
se_id:
type:
- string
- 'null'
designer_id:
type:
- string
- 'null'
product_ops_id:
type:
- string
- 'null'
notes:
type:
- string
- 'null'
waiting_on_customer:
type: boolean
waiting_on_customer_since:
type:
- string
- 'null'
format: date-time
wizard_completed_at:
type:
- string
- 'null'
format: date-time
reminder_email:
type: boolean
reminder_slack:
type: boolean
reminder_frequency_days:
type: integer
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- template_id
- phase
- state
- started_at
- completed_at
- target_launch_date
- csm_id
- se_id
- designer_id
- product_ops_id
- notes
- waiting_on_customer
- waiting_on_customer_since
- wizard_completed_at
- reminder_email
- reminder_slack
- reminder_frequency_days
- created_at
- updated_at
title: Onboarding
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/onboardings"
payload = { "customer_id": "cust_1234567890" }
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/onboardings';
const options = {
method: 'POST',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{"customer_id":"cust_1234567890"}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/onboardings"
payload := strings.NewReader("{\n \"customer_id\": \"cust_1234567890\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/onboardings")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{\n \"customer_id\": \"cust_1234567890\"\n}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.post("https://my.pivotal.app/api/v1/onboardings")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{\n \"customer_id\": \"cust_1234567890\"\n}")
.asString();
```
```php
request('POST', 'https://my.pivotal.app/api/v1/onboardings', [
'body' => '{
"customer_id": "cust_1234567890"
}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/onboardings");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n \"customer_id\": \"cust_1234567890\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = ["customer_id": "cust_1234567890"] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/onboardings")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# List a customer's onboardings
GET https://my.pivotal.app/api/v1/customers/{customerId}/onboardings
Newest first. Not paginated — a customer rarely has more than a handful.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/onboardings/list-customer-onboardings
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/customers/{customerId}/onboardings:
get:
operationId: list-customer-onboardings
summary: List a customer's onboardings
description: Newest first. Not paginated — a customer rarely has more than a handful.
tags:
- subpackage_onboardings
parameters:
- name: customerId
in: path
description: Numeric display_id or cuid of the parent customer.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingList'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Customer not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
OnboardingListObject:
type: string
enum:
- list
title: OnboardingListObject
OnboardingObject:
type: string
enum:
- onboarding
title: OnboardingObject
OnboardingPhase:
type: string
enum:
- before_getting_started
- program_design
- review
- program_buildout
- internal_qa
- launch
- completed
title: OnboardingPhase
OnboardingState:
type: string
enum:
- active
- paused
- at_risk
- waiting
title: OnboardingState
Onboarding:
type: object
properties:
object:
$ref: '#/components/schemas/OnboardingObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
template_id:
type:
- string
- 'null'
phase:
$ref: '#/components/schemas/OnboardingPhase'
state:
$ref: '#/components/schemas/OnboardingState'
started_at:
type: string
format: date-time
completed_at:
type:
- string
- 'null'
format: date-time
target_launch_date:
type:
- string
- 'null'
format: date-time
csm_id:
type:
- string
- 'null'
se_id:
type:
- string
- 'null'
designer_id:
type:
- string
- 'null'
product_ops_id:
type:
- string
- 'null'
notes:
type:
- string
- 'null'
waiting_on_customer:
type: boolean
waiting_on_customer_since:
type:
- string
- 'null'
format: date-time
wizard_completed_at:
type:
- string
- 'null'
format: date-time
reminder_email:
type: boolean
reminder_slack:
type: boolean
reminder_frequency_days:
type: integer
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- template_id
- phase
- state
- started_at
- completed_at
- target_launch_date
- csm_id
- se_id
- designer_id
- product_ops_id
- notes
- waiting_on_customer
- waiting_on_customer_since
- wizard_completed_at
- reminder_email
- reminder_slack
- reminder_frequency_days
- created_at
- updated_at
title: Onboarding
OnboardingList:
type: object
properties:
object:
$ref: '#/components/schemas/OnboardingListObject'
data:
type: array
items:
$ref: '#/components/schemas/Onboarding'
has_more:
type: boolean
next_cursor:
type:
- string
- 'null'
required:
- object
- data
- has_more
- next_cursor
title: OnboardingList
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/customers/42/onboardings"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/customers/42/onboardings';
const options = {
method: 'GET',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/customers/42/onboardings"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/customers/42/onboardings")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/customers/42/onboardings")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/customers/42/onboardings', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/customers/42/onboardings");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/customers/42/onboardings")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Retrieve an onboarding
GET https://my.pivotal.app/api/v1/onboardings/{id}
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/onboardings/get-onboarding
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/onboardings/{id}:
get:
operationId: get-onboarding
summary: Retrieve an onboarding
tags:
- subpackage_onboardings
parameters:
- name: id
in: path
description: Numeric display_id or cuid of the onboarding.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Onboarding'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Onboarding not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
OnboardingObject:
type: string
enum:
- onboarding
title: OnboardingObject
OnboardingPhase:
type: string
enum:
- before_getting_started
- program_design
- review
- program_buildout
- internal_qa
- launch
- completed
title: OnboardingPhase
OnboardingState:
type: string
enum:
- active
- paused
- at_risk
- waiting
title: OnboardingState
Onboarding:
type: object
properties:
object:
$ref: '#/components/schemas/OnboardingObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
template_id:
type:
- string
- 'null'
phase:
$ref: '#/components/schemas/OnboardingPhase'
state:
$ref: '#/components/schemas/OnboardingState'
started_at:
type: string
format: date-time
completed_at:
type:
- string
- 'null'
format: date-time
target_launch_date:
type:
- string
- 'null'
format: date-time
csm_id:
type:
- string
- 'null'
se_id:
type:
- string
- 'null'
designer_id:
type:
- string
- 'null'
product_ops_id:
type:
- string
- 'null'
notes:
type:
- string
- 'null'
waiting_on_customer:
type: boolean
waiting_on_customer_since:
type:
- string
- 'null'
format: date-time
wizard_completed_at:
type:
- string
- 'null'
format: date-time
reminder_email:
type: boolean
reminder_slack:
type: boolean
reminder_frequency_days:
type: integer
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- template_id
- phase
- state
- started_at
- completed_at
- target_launch_date
- csm_id
- se_id
- designer_id
- product_ops_id
- notes
- waiting_on_customer
- waiting_on_customer_since
- wizard_completed_at
- reminder_email
- reminder_slack
- reminder_frequency_days
- created_at
- updated_at
title: Onboarding
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/onboardings/17"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.get(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/onboardings/17';
const options = {
method: 'GET',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/onboardings/17"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("GET", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/onboardings/17")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.get("https://my.pivotal.app/api/v1/onboardings/17")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('GET', 'https://my.pivotal.app/api/v1/onboardings/17', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/onboardings/17");
var request = new RestRequest(Method.GET);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/onboardings/17")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Delete an onboarding
DELETE https://my.pivotal.app/api/v1/onboardings/{id}
Soft delete — preserved for audit, hidden from reads.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/onboardings/delete-onboarding
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/onboardings/{id}:
delete:
operationId: delete-onboarding
summary: Delete an onboarding
description: Soft delete — preserved for audit, hidden from reads.
tags:
- subpackage_onboardings
parameters:
- name: id
in: path
description: Numeric display_id or cuid of the onboarding.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/DeletedOnboarding'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Onboarding not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
servers:
- url: https://my.pivotal.app
components:
schemas:
DeletedOnboardingObject:
type: string
enum:
- deleted
title: DeletedOnboardingObject
DeletedOnboarding:
type: object
properties:
object:
$ref: '#/components/schemas/DeletedOnboardingObject'
id:
type: string
display_id:
type: integer
deleted:
type: boolean
required:
- object
- id
- display_id
- deleted
title: DeletedOnboarding
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/onboardings/17"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.delete(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/onboardings/17';
const options = {
method: 'DELETE',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/onboardings/17"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("DELETE", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/onboardings/17")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Delete.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.delete("https://my.pivotal.app/api/v1/onboardings/17")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('DELETE', 'https://my.pivotal.app/api/v1/onboardings/17', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/onboardings/17");
var request = new RestRequest(Method.DELETE);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/onboardings/17")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "DELETE"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# Update an onboarding
PATCH https://my.pivotal.app/api/v1/onboardings/{id}
Content-Type: application/json
Partial update. Setting `state=waiting` flips `waiting_on_customer=true`; setting any other state clears it.
Reference: https://docs.pivotal.app/rest-api/api-reference/pivotal-api/onboardings/update-onboarding
## OpenAPI Specification
```yaml
openapi: 3.1.0
info:
title: openapi
version: 1.0.0
paths:
/api/v1/onboardings/{id}:
patch:
operationId: update-onboarding
summary: Update an onboarding
description: >-
Partial update. Setting `state=waiting` flips
`waiting_on_customer=true`; setting any other state clears it.
tags:
- subpackage_onboardings
parameters:
- name: id
in: path
description: Numeric display_id or cuid of the onboarding.
required: true
schema:
type: string
- name: Authorization
in: header
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Onboarding'
'400':
description: Invalid request body
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Onboarding not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingUpdate'
servers:
- url: https://my.pivotal.app
components:
schemas:
OnboardingUpdatePhase:
type: string
enum:
- before_getting_started
- program_design
- review
- program_buildout
- internal_qa
- launch
- completed
title: OnboardingUpdatePhase
OnboardingUpdateState:
type: string
enum:
- active
- paused
- at_risk
- waiting
title: OnboardingUpdateState
OnboardingUpdate:
type: object
properties:
template_id:
type:
- string
- 'null'
phase:
$ref: '#/components/schemas/OnboardingUpdatePhase'
state:
$ref: '#/components/schemas/OnboardingUpdateState'
target_launch_date:
type:
- string
- 'null'
format: date-time
completed_at:
type:
- string
- 'null'
format: date-time
csm_id:
type:
- string
- 'null'
se_id:
type:
- string
- 'null'
designer_id:
type:
- string
- 'null'
product_ops_id:
type:
- string
- 'null'
notes:
type:
- string
- 'null'
waiting_on_customer:
type: boolean
reminder_email:
type: boolean
reminder_slack:
type: boolean
reminder_frequency_days:
type: integer
title: OnboardingUpdate
OnboardingObject:
type: string
enum:
- onboarding
title: OnboardingObject
OnboardingPhase:
type: string
enum:
- before_getting_started
- program_design
- review
- program_buildout
- internal_qa
- launch
- completed
title: OnboardingPhase
OnboardingState:
type: string
enum:
- active
- paused
- at_risk
- waiting
title: OnboardingState
Onboarding:
type: object
properties:
object:
$ref: '#/components/schemas/OnboardingObject'
id:
type: string
display_id:
type: integer
customer_id:
type: string
template_id:
type:
- string
- 'null'
phase:
$ref: '#/components/schemas/OnboardingPhase'
state:
$ref: '#/components/schemas/OnboardingState'
started_at:
type: string
format: date-time
completed_at:
type:
- string
- 'null'
format: date-time
target_launch_date:
type:
- string
- 'null'
format: date-time
csm_id:
type:
- string
- 'null'
se_id:
type:
- string
- 'null'
designer_id:
type:
- string
- 'null'
product_ops_id:
type:
- string
- 'null'
notes:
type:
- string
- 'null'
waiting_on_customer:
type: boolean
waiting_on_customer_since:
type:
- string
- 'null'
format: date-time
wizard_completed_at:
type:
- string
- 'null'
format: date-time
reminder_email:
type: boolean
reminder_slack:
type: boolean
reminder_frequency_days:
type: integer
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- object
- id
- display_id
- customer_id
- template_id
- phase
- state
- started_at
- completed_at
- target_launch_date
- csm_id
- se_id
- designer_id
- product_ops_id
- notes
- waiting_on_customer
- waiting_on_customer_since
- wizard_completed_at
- reminder_email
- reminder_slack
- reminder_frequency_days
- created_at
- updated_at
title: Onboarding
ErrorErrorType:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- not_found
- conflict
- internal_error
title: ErrorErrorType
ErrorError:
type: object
properties:
type:
$ref: '#/components/schemas/ErrorErrorType'
code:
type: string
message:
type: string
field:
type: string
required:
- type
- code
- message
title: ErrorError
Error:
type: object
properties:
error:
$ref: '#/components/schemas/ErrorError'
required:
- error
title: Error
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Send your key in the `Authorization` header. Keys start with
`pivotal_` (production) or `pivotal_test_` (test mode, no side
effects on integrations). Rotate keys from
`/admin/api-keys` — old keys 401 instantly when revoked.
```
## SDK Code Examples
```python
import requests
url = "https://my.pivotal.app/api/v1/onboardings/17"
payload = {}
headers = {
"Authorization": "Bearer ",
"Content-Type": "application/json"
}
response = requests.patch(url, json=payload, headers=headers)
print(response.json())
```
```javascript
const url = 'https://my.pivotal.app/api/v1/onboardings/17';
const options = {
method: 'PATCH',
headers: {Authorization: 'Bearer ', 'Content-Type': 'application/json'},
body: '{}'
};
try {
const response = await fetch(url, options);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
```
```go
package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://my.pivotal.app/api/v1/onboardings/17"
payload := strings.NewReader("{}")
req, _ := http.NewRequest("PATCH", url, payload)
req.Header.Add("Authorization", "Bearer ")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res)
fmt.Println(string(body))
}
```
```ruby
require 'uri'
require 'net/http'
url = URI("https://my.pivotal.app/api/v1/onboardings/17")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Patch.new(url)
request["Authorization"] = 'Bearer '
request["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body
```
```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;
HttpResponse response = Unirest.patch("https://my.pivotal.app/api/v1/onboardings/17")
.header("Authorization", "Bearer ")
.header("Content-Type", "application/json")
.body("{}")
.asString();
```
```php
request('PATCH', 'https://my.pivotal.app/api/v1/onboardings/17', [
'body' => '{}',
'headers' => [
'Authorization' => 'Bearer ',
'Content-Type' => 'application/json',
],
]);
echo $response->getBody();
```
```csharp
using RestSharp;
var client = new RestClient("https://my.pivotal.app/api/v1/onboardings/17");
var request = new RestRequest(Method.PATCH);
request.AddHeader("Authorization", "Bearer ");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```
```swift
import Foundation
let headers = [
"Authorization": "Bearer ",
"Content-Type": "application/json"
]
let parameters = [] as [String : Any]
let postData = JSONSerialization.data(withJSONObject: parameters, options: [])
let request = NSMutableURLRequest(url: NSURL(string: "https://my.pivotal.app/api/v1/onboardings/17")! as URL,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
request.httpMethod = "PATCH"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data
let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
if (error != nil) {
print(error as Any)
} else {
let httpResponse = response as? HTTPURLResponse
print(httpResponse)
}
})
dataTask.resume()
```
# SDKs
Both SDKs are generated from the same OpenAPI spec that powers this reference. Method names, parameters, and response types match the spec exactly. When the API changes additively, the next SDK release picks up the new fields without you touching anything.
Published SDK packages will land at `@pivotal/api` (npm) and `pivotal` (PyPI). Until then, generate locally with `fern generate --group local-sdks` against this repo. The hand-rolled snippets below show the shape — install steps are coming with the first published release.
## TypeScript / Node
Once published:
```bash
npm install @pivotal/api
# or
bun add @pivotal/api
```
```typescript
import { PivotalApi } from "@pivotal/api";
const pivotal = new PivotalApi({ apiKey: process.env.PIVOTAL_API_KEY });
// Create a customer
const customer = await pivotal.customers.create({
name: "Aurora Outfitters",
slug: "aurora",
status: "onboarding",
});
// Walk all active onboardings
for await (const onb of pivotal.onboardings.list({ state: "active" })) {
console.log(onb.display_id, onb.name);
}
```
Highlights:
* **`async` iterator** on every `list*` method handles cursor pagination for you. `for await` walks the full set.
* **Strict types** generated from the schema — autocomplete on request bodies, return values typed by `operationId`.
* **Configurable fetcher.** Inject your own `fetch` for retries, tracing, or test mocks.
* **Bundle size** under 30 KB minified+gzipped (rolled from spec, no runtime bloat).
## Python
Once published:
```bash
pip install pivotal
# or
uv add pivotal
```
```python
import os
from pivotal import PivotalApi
pivotal = PivotalApi(api_key=os.environ["PIVOTAL_API_KEY"])
customer = pivotal.customers.create(
name="Aurora Outfitters",
slug="aurora",
status="onboarding",
)
for onb in pivotal.onboardings.list(state="active"):
print(onb.display_id, onb.name)
```
Highlights:
* **Pydantic v2 models** for every request and response.
* **Generator-based pagination** — `list()` yields rows, walks the cursor under the hood.
* **`httpx` under the hood** with sane timeouts and connection pooling.
* **Async client** at `pivotal.AsyncPivotalApi` with the same surface.
## Other languages
The OpenAPI spec is the contract. Generate a client in your language of choice from:
```
https://my.pivotal.app/api/v1/openapi.json
```
Recommended generators:
* **Go** — [`oapi-codegen`](https://github.com/oapi-codegen/oapi-codegen)
* **Ruby** — `openapi-generator` with `ruby` target
* **C#** — `nswag` or `openapi-generator`
* **Rust** — `progenitor` or `openapi-generator`
If you generate against the spec yourself, the names of request bodies and response shapes match the `operationId`s — `createCustomer`, `listOnboardings`, etc. — so cross-referencing this site stays straightforward.
## Versioning the SDKs
SDK versions follow semver:
* **Patch** (`1.0.x`) — generator updates, bug fixes.
* **Minor** (`1.x.0`) — new endpoints, new optional fields, new enum values. Always backwards-compatible.
* **Major** (`x.0.0`) — only when the underlying API ships a `v2`.
Pin to a major version in production. Renovate / Dependabot can safely auto-bump minors.
## Reporting SDK bugs
If the SDK behaves differently than this reference says it should, that's a bug in the generator config. Email [help@pivotal.app](mailto:help@pivotal.app) with the SDK version (`pivotal.__version__` / `PivotalApi.VERSION`), the method you called, and the response you got.
# Changelog
PIVOTAL API CHANGELOG
Every shipped change to the Pivotal API and developer surface — additive in `v1`, breaking changes ship as `v2`. Each entry is dated and links to relevant docs.
Entries land here on release. For announcements of in-app product changes, see the [Help Center](/help).
# Webhooks
Stream Pivotal events to your own systems. Subscribe to an endpoint, pick the events you care about, and we'll POST a signed payload whenever they happen. Deliveries are retried for 24 hours with exponential backoff.
Outbound delivery runs on [Svix](https://www.svix.com). The signing headers, retry schedule, and verification libraries are the standard Svix ones — drop in any Svix SDK and it works against Pivotal without custom code.
SUBSCRIBE
1. Open **Admin → Developer → Webhooks** in your workspace.
2. Click **Add endpoint**, paste your HTTPS URL, pick the event types to receive.
3. Copy the **signing secret** — Svix shows it once. Store it in your secret manager.
4. Test from the dashboard with **Send test event**. The same UI lists every past delivery with status, payload, and a one-click **Replay**.
WHAT TO READ NEXT
Receive your first event in under five minutes. Local tunnel + signing verification.
The standard shape every payload arrives in. id, type, timestamp, org, data.object.
Node/Express and Python/FastAPI handlers, ready to copy.
HMAC verification in TypeScript and Python.
24h exponential schedule. What counts as success. When delivery gives up.
Fire real events from `pivotal_test_` keys without polluting production data.
EVENT REFERENCE
Fifteen event types ship today, grouped by resource. Each page documents the payload shape, when it fires, a copyable JSON example, and common use cases.
Created, updated, archived, health score dropped, at risk, churned.
Created.
Created, phase changed, state changed, at risk, back on track, launched, completed.
Completed.
# Webhooks quickstart
Receive your first Pivotal event on a local server in under five minutes. The flow: tunnel a local port to a public URL, register the URL as a Pivotal webhook endpoint, send a test event from the dashboard, verify the signature on receipt.
PREREQUISITES
* Node 20+ or Python 3.10+
* An admin role on your Pivotal workspace (you'll need to register the endpoint)
* A local tunnel — [ngrok](https://ngrok.com), [Cloudflare tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/), or [Tailscale Funnel](https://tailscale.com/kb/1223/funnel/)
1\. RUN A LOCAL RECEIVER
```typescript title="webhook-receiver.ts"
import { Webhook } from "svix";
import { createServer } from "node:http";
const SECRET = process.env.PIVOTAL_WEBHOOK_SECRET!;
const wh = new Webhook(SECRET);
createServer((req, res) => {
if (req.method !== "POST") return res.writeHead(405).end();
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
const headers = {
"svix-id": req.headers["svix-id"] as string,
"svix-timestamp": req.headers["svix-timestamp"] as string,
"svix-signature": req.headers["svix-signature"] as string,
};
try {
const event = wh.verify(body, headers) as { type: string; id: string };
console.log(`${event.type} · ${event.id}`);
res.writeHead(200).end("ok");
} catch {
res.writeHead(401).end("invalid signature");
}
});
}).listen(8787, () => console.log("listening on :8787"));
```
```bash
bun webhook-receiver.ts
# or: ts-node webhook-receiver.ts
```
2\. EXPOSE THE PORT
Pick whichever tunnel you already use. ngrok example:
```bash
ngrok http 8787
# Forwarding https://aurora-1234.ngrok.app -> http://localhost:8787
```
Copy the `https://` URL.
3\. REGISTER THE ENDPOINT
1. Open [Admin → Developer → Webhooks](https://my.pivotal.app/admin/webhooks)
2. Click **Add endpoint**
3. Paste the ngrok URL
4. Pick `customer.created` and `task.completed` for now
5. Copy the **signing secret** and `export PIVOTAL_WEBHOOK_SECRET=…` in your receiver shell
4\. FIRE A TEST EVENT
In the Webhooks dashboard, click your endpoint, then **Send test event**. Pick `customer.created`. Your terminal logs the event type + id, the dashboard shows a green check, and the **Logs** tab records the delivery with the full payload.
5\. WHAT TO READ NEXT
* [Event envelope](/webhooks/envelope) — the standard shape of every payload
* [Signature verification](/webhooks/signing) — what the Svix SDK does under the hood
* [Retries and backoff](/webhooks/retries) — what happens when your server returns non-2xx
Stuck? Email **[help@pivotal.app](mailto:help@pivotal.app)** with your endpoint URL and the Pivotal event id (`evt_…`) from the delivery log.
# Event envelope
Every webhook arrives in the same envelope. The fields below are present on every event.
```json title="event envelope"
{
"id": "evt_2nQv8c9aZmYHKr1...",
"type": "customer.health_score_dropped",
"created_at": "2026-05-26T18:42:00.000Z",
"org_id": "org_2bH...",
"livemode": true,
"api_version": "v1",
"data": {
"object": {
"id": "cus_2N...",
"display_id": 4218,
"name": "Acme Corp",
"health_score": 38,
"previous_health_score": 72
},
"previous_attributes": {
"health_score": 72
}
}
}
```
FIELDS
| Field | Type | Notes |
| -------------------------- | -------------- | ---------------------------------------------------------- |
| `id` | string | Stable event id. Use it for idempotent processing. |
| `type` | string | Dot-namespaced event type, e.g. `customer.created`. |
| `created_at` | ISO 8601 | When the event fired, not when we delivered it. |
| `org_id` | string | The workspace that owns the resource. |
| `livemode` | boolean | `false` for events from `pivotal_test_` keys. |
| `api_version` | string | Always `v1` today. Reserved for future versioning. |
| `data.object` | object | The full resource at the time of the event. |
| `data.previous_attributes` | object \| null | Present on `*.updated` events with the prior field values. |
WHAT TO READ NEXT
* [Receiving webhooks](/webhooks/receiving) — copyable Node and Python handlers
* [Signature verification](/webhooks/signing) — HMAC + Svix SDK
* [Retries and backoff](/webhooks/retries) — when delivery gives up
# Receiving webhooks
Copy a handler, set `PIVOTAL_WEBHOOK_SECRET` from the endpoint's signing secret, point Pivotal at the URL.
NODE / EXPRESS
```typescript title="webhook-handler.ts"
import express from "express";
import { Webhook } from "svix";
const app = express();
const wh = new Webhook(process.env.PIVOTAL_WEBHOOK_SECRET!);
app.post("/webhooks/pivotal", express.raw({ type: "application/json" }), (req, res) => {
try {
const evt = wh.verify(req.body, {
"svix-id": req.header("svix-id")!,
"svix-timestamp": req.header("svix-timestamp")!,
"svix-signature": req.header("svix-signature")!,
}) as { id: string; type: string; data: { object: any } };
// Idempotent: skip if you've already processed evt.id
handle(evt);
res.status(200).send("ok");
} catch {
res.status(401).send("invalid signature");
}
});
```
PYTHON / FASTAPI
```python title="webhook_handler.py"
from fastapi import FastAPI, Request, HTTPException
from svix.webhooks import Webhook, WebhookVerificationError
import os
app = FastAPI()
wh = Webhook(os.environ["PIVOTAL_WEBHOOK_SECRET"])
@app.post("/webhooks/pivotal")
async def receive(request: Request):
body = await request.body()
try:
evt = wh.verify(body, {
"svix-id": request.headers["svix-id"],
"svix-timestamp": request.headers["svix-timestamp"],
"svix-signature": request.headers["svix-signature"],
})
except WebhookVerificationError:
raise HTTPException(401, "invalid signature")
handle(evt)
return "ok"
```
RULES
* Return a 2xx within 10 seconds. Anything else triggers a retry.
* Read the raw body before parsing JSON. Signature verification fails on re-stringified payloads.
* Idempotency: every retry carries the same `id`. Store processed ids for at least 7 days.
* Order is best-effort, not guaranteed. Prefer reading state from `data.object` over inferring from event order.
# Signature verification
Pivotal signs every delivery with the endpoint's signing secret. The Svix SDK handles verification for you; this page documents what it does so you can implement it manually if you want.
HEADERS ON EVERY REQUEST
| Header | Example | Notes |
| ---------------- | -------------------------------------- | --------------------------------------------- |
| `svix-id` | `msg_2nQv8c9aZmYHKr1...` | Delivery id. Different from `event.id`. |
| `svix-timestamp` | `1748286120` | Unix seconds. Reject if older than 5 minutes. |
| `svix-signature` | `v1,EXample...,v1,EXampleSecondary...` | Space-separated `version,signature` pairs. |
VERIFICATION RECIPE
```typescript title="manual-verify.ts"
import crypto from "node:crypto";
function verify(secret: string, id: string, ts: string, sig: string, body: string) {
// signing secret is base64; the value after "whsec_"
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const toSign = `${id}.${ts}.${body}`;
const expected = crypto.createHmac("sha256", key).update(toSign).digest("base64");
// sig header can hold multiple signatures (rotation)
const ours = sig.split(" ").some((pair) => pair.split(",")[1] === expected);
if (!ours) throw new Error("bad signature");
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) {
throw new Error("timestamp too old");
}
}
```
SECRET ROTATION
The Webhooks dashboard lets you rotate the signing secret with a 24-hour overlap. Deliveries during the overlap window carry two signatures so your old key keeps validating while you deploy the new one. The `svix-signature` header lists both, separated by a space — see the recipe above for how to handle multiple candidates.
# Retries and backoff
Pivotal retries failed deliveries for 24 hours with exponential backoff. After the final retry, the endpoint is marked unhealthy and we email the workspace owner. The full delivery log lives in the dashboard with one-click replay.
WHAT COUNTS AS SUCCESS
* Any 2xx response inside the 10-second window.
WHAT COUNTS AS FAILURE
* Non-2xx response (4xx and 5xx alike).
* Connection refused, DNS failure, TLS handshake failure.
* Timeout: no response inside 10 seconds.
RETRY SCHEDULE
| Attempt | Delay after previous |
| ------- | ----------------------------------- |
| 1 | immediate |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 24 hours from first attempt — final |
WHEN DELIVERY GIVES UP
After attempt 8, Pivotal:
1. Marks the delivery as `failed` in the dashboard.
2. Increments the endpoint's failure counter.
3. Disables the endpoint if it has crossed 100 consecutive failures.
4. Emails the workspace owner with a link to the delivery log.
You can re-fire any past delivery from the dashboard — payload and headers are identical to the original. The Svix SDK treats replays as new deliveries with the same `event.id`, so your idempotent handler keeps you safe.
# Testing
Two ways to exercise a webhook endpoint without touching production data: the dashboard's **Send test event** button, and `pivotal_test_` API keys.
DASHBOARD TEST EVENTS
1. Open **Admin → Developer → Webhooks**.
2. Pick your endpoint, click **Send test event**.
3. Choose an event type. The dashboard fills in a realistic payload — you can edit the JSON before sending.
4. The delivery shows up in the **Logs** tab with full request and response.
Test events from this button carry `livemode: false` and a fake resource id. They will not appear in your Pivotal app's activity feed.
TEST MODE KEYS
`pivotal_test_…` keys write to the same workspace, but outbound integration side effects (Slack, Resend, downstream webhook deliveries to your live receivers) are suppressed. Webhook endpoints flagged `test_mode: true` receive these events; live endpoints do not.
See [Test mode](/api/guides/test-mode) for the full rules.
LOCAL DEVELOPMENT
Pair the Webhooks dashboard with a local tunnel — see [Local tunneling](/webhooks/tools/tunneling). The flow:
1. Run your receiver on `localhost:8787`.
2. Start ngrok or Cloudflare Tunnel.
3. Register the tunnel URL as a Webhook endpoint, flagged **Test mode**.
4. Fire events from the dashboard.
# Customer events
Six events fire on the `customer` resource. Each delivers the full customer object on `data.object`. Update events also include `data.previous_attributes` with the prior values of the changed fields.
EVENT TYPES
| Type | Fires when |
| ------------------------------- | ----------------------------------------------------------------------------------- |
| `customer.created` | A customer is added — through the app, the API, or a CRM sync. |
| `customer.updated` | Any field on the customer changes. `previous_attributes` lists changed fields only. |
| `customer.archived` | A customer is archived. The object remains but stops appearing in the active list. |
| `customer.health_score_dropped` | Health score drops by 10 points or more between calculations. |
| `customer.at_risk` | Customer's at-risk flag is set, either by Pi or by a CSM. |
| `customer.churned` | Customer is marked churned. |
PAYLOAD
```json title="customer.health_score_dropped"
{
"id": "evt_2nQv...",
"type": "customer.health_score_dropped",
"data": {
"object": {
"id": "cus_2N...",
"display_id": 4218,
"name": "Acme Corp",
"health_score": 38,
"previous_health_score": 72,
"at_risk": true,
"churned": false,
"owner_email": "csm@yourdomain.com"
},
"previous_attributes": {
"health_score": 72,
"at_risk": false
}
}
}
```
# Contact events
One event fires on the `contact` resource today. Additional events (`contact.updated`, `contact.deleted`) are typed and tracked internally — subscribe to `contact.created` for now and watch the [Changelog](/webhooks/changelog) for new types.
EVENT TYPES
| Type | Fires when |
| ----------------- | ------------------------------------ |
| `contact.created` | A contact is attached to a customer. |
PAYLOAD
```json title="contact.created"
{
"id": "evt_2nQv...",
"type": "contact.created",
"data": {
"object": {
"id": "con_2N...",
"customer_id": "cus_2N...",
"display_id": 8421,
"name": "Jordan Lee",
"email": "jordan@acme.com",
"title": "Head of Operations",
"is_primary": true
}
}
}
```
# Onboarding events
Seven events fire on the `onboarding` resource. Phase and state changes carry `data.previous_attributes` so you can act on the transition, not just the new state.
EVENT TYPES
| Type | Fires when |
| -------------------------- | ----------------------------------------------------------------------------------------- |
| `onboarding.created` | A new onboarding is created for a customer. |
| `onboarding.phase_changed` | Onboarding moves to a different phase. `previous_attributes.phase` holds the prior phase. |
| `onboarding.state_changed` | Onboarding's state changes (active, paused, blocked, completed). |
| `onboarding.at_risk` | At-risk flag is set on the onboarding. |
| `onboarding.back_on_track` | At-risk flag is cleared. |
| `onboarding.launched` | Onboarding is marked launched (live with end users). |
| `onboarding.completed` | Onboarding hits its terminal completed state. |
PAYLOAD
```json title="onboarding.phase_changed"
{
"id": "evt_2nQv...",
"type": "onboarding.phase_changed",
"data": {
"object": {
"id": "onb_2N...",
"customer_id": "cus_2N...",
"display_id": 1108,
"name": "Acme Corp · Q3 rollout",
"phase": "integration",
"state": "active",
"target_launch_date": "2026-07-15",
"owner_email": "csm@yourdomain.com"
},
"previous_attributes": {
"phase": "kickoff"
}
}
}
```
# Task events
One event fires on the `task` resource. Tasks live under an onboarding — both ids are on the payload so you can route to the right team without a second lookup.
EVENT TYPES
| Type | Fires when |
| ---------------- | ------------------------------------------------------------------------------- |
| `task.completed` | A task is marked complete by a teammate, by a portal user, or by an automation. |
PAYLOAD
```json title="task.completed"
{
"id": "evt_2nQv...",
"type": "task.completed",
"data": {
"object": {
"id": "tsk_2N...",
"onboarding_id": "onb_2N...",
"customer_id": "cus_2N...",
"display_id": 5102,
"title": "Share API keys with the Acme team",
"completed_at": "2026-05-26T14:08:32.000Z",
"completed_by": "portal:contact_2N...",
"assignee_email": "csm@yourdomain.com"
}
}
}
```
# SDKs
Pivotal webhooks ride on Svix, so any Svix SDK verifies signatures without custom code. Pick the one that matches your stack.
OFFICIAL SVIX SDKS
| Language | Install | Docs |
| ----------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------- |
| TypeScript / Node | `npm i svix` | [svix.com/docs/receiving/node](https://docs.svix.com/receiving/verifying-payloads/how-manual) |
| Python | `pip install svix` | [svix.com/docs/receiving/python](https://docs.svix.com/receiving/verifying-payloads/how-manual) |
| Go | `go get github.com/svix/svix-webhooks/go` | [svix.com/docs/receiving/go](https://docs.svix.com/receiving/verifying-payloads/how-manual) |
| Ruby | `gem install svix` | [svix.com/docs/receiving/ruby](https://docs.svix.com/receiving/verifying-payloads/how-manual) |
| Rust | `cargo add svix` | [docs.rs/svix](https://docs.rs/svix) |
| Java | Maven `com.svix:svix:+` | [svix.com/docs/receiving/java](https://docs.svix.com/receiving/verifying-payloads/how-manual) |
MANUAL VERIFICATION
If you can't pull in an SDK, [Signature verification](/webhooks/signing) documents the HMAC recipe in TypeScript and Python.
# Local tunneling
Three ways to expose a local receiver. Pick whichever you already have a habit with.
NGROK
```bash
ngrok http 8787
# Forwarding https://aurora-1234.ngrok.app -> http://localhost:8787
```
Free tier rotates the subdomain on every restart. Paid plans pin a custom domain.
CLOUDFLARE TUNNEL
```bash
cloudflared tunnel --url http://localhost:8787
```
No account required for ephemeral tunnels. Persistent tunnels need a Cloudflare account and a domain.
TAILSCALE FUNNEL
```bash
tailscale funnel 8787
```
Works if your device is already on Tailscale. URL persists across restarts.
REGISTERING THE URL
Copy the HTTPS URL and paste it into **Admin → Developer → Webhooks → Add endpoint**. Toggle **Test mode** on so live deliveries don't hit your laptop.
# Replay and resend
Every delivery is replayable from the Webhooks dashboard. Use it to debug a handler after fixing a bug, or to resend a batch you missed during an outage.
REPLAY ONE DELIVERY
1. Open **Admin → Developer → Webhooks** and click the endpoint.
2. Find the delivery in the **Logs** tab — filter by status, event type, or time range.
3. Click **Replay**. The request goes out with identical headers and body. The `svix-id` matches the original.
REPLAY A WINDOW
The endpoint page has a **Replay missing** button that re-delivers every event that hit a non-2xx response in the last N hours (you pick the window). Duplicates are safe — your idempotent handler keys on `event.id`.
CUSTOM RESEND
For an arbitrary backfill, hit `POST /api/v1/webhook_endpoints/:id/resend` with an event id list. The dashboard equivalent is **Replay selected** after multi-selecting in the Logs tab.
# Webhooks changelog
Newest first. Subscribe to [Pivotal's status page](https://status.pivotal.app) for delivery incidents.
2026-05-26
* Webhooks tab launched as a top-level product in the docs.
* Event envelope, signing, retries, and testing pages published.
* Four event reference groups: customer (6), contact (1), onboarding (7), task (1).
2026-04-12
* Replay-missing button added to the endpoint logs view.
2026-03-04
* Signing secret rotation with a 24-hour overlap window.
# Setup
Spin up a workspace, invite your team, and connect the integrations Pivotal reads from. Ten short reads, in the order you'll touch them.
GET YOUR WORKSPACE READY
First-time setup — name, time zone, and admin owner.
Seats, roles, and bulk invites.
Owner, admin, member — what each can do.
Light and dark logos, brand color, favicon.
What customers see when they visit your portal.
Mirror customers from HubSpot, Salesforce, or Stripe.
Pricing tiers and how seats work.
Add a card, change the card, view receipts.
Where to find invoices and how to forward them.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with the article you're on and a screenshot if you're stuck.
# Create your workspace
A workspace is the container for every customer, contact, onboarding, and task. Most companies run with one workspace; create extras only if you have separate brands that don't share teammates.
IN THIS ARTICLE
* Pick a name your team will recognize (it shows in the portal URL and emails).
* Set the time zone — task due dates and Pi summaries follow it.
* Confirm the workspace owner (the only role that can add other admins).
WHERE TO GO IN THE APP
**Admin → Workspace settings** → **General**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Invite your team
Add teammates by email, pick a role, send. Pivotal mails them a magic link; no password to manage.
IN THIS ARTICLE
* Single invite: type an email, pick a role, send.
* Bulk: paste up to 50 comma-separated emails.
* Resend or revoke from the **Pending** tab.
WHERE TO GO IN THE APP
**Admin → Team** → **Invite**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Roles and permissions
Three roles ship today: Owner, Admin, Member. Roles are workspace-wide; per-customer access lives on the customer record.
IN THIS ARTICLE
* **Owner**: billing, owner transfer, workspace deletion. One per workspace.
* **Admin**: every action except the owner-only ones. Manages team and integrations.
* **Member**: works in the app, can't change workspace settings.
WHERE TO GO IN THE APP
**Admin → Team** → click a teammate to change their role.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Branding and logos
Upload one logo for light and one for dark. Pivotal swaps based on the customer's portal theme.
IN THIS ARTICLE
* Logos render up to 32px tall — vector or 2x PNG looks best.
* Brand color shows in the portal CTA and accent links.
* The favicon defaults to your logo's square crop.
WHERE TO GO IN THE APP
**Admin → Branding**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Portal settings
The portal is the customer-facing surface — what your customers' teams see when they log in. Pick a slug, decide which sections show, write the welcome line.
IN THIS ARTICLE
* Portal URL: `yourname.pivotal.app/portal` by default, custom domain on Pro.
* Sections: Tasks, Resources, Comments, Team.
* Welcome message: one to two sentences, supports markdown.
WHERE TO GO IN THE APP
**Admin → Portal**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Connect your CRM
Mirror customers from HubSpot, Salesforce, or Stripe so Pivotal stays in step with your revenue source of truth. Per-integration setup lives under the [Integrations](/product/integrations) category.
IN THIS ARTICLE
* One integration at a time is the supported path.
* Sync runs nightly plus on-demand from the integration page.
* New customers in your CRM appear in Pivotal within an hour.
WHERE TO GO IN THE APP
**Admin → Integrations** → pick a provider.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Plans and seats
Pivotal bills monthly or annually per active seat. Add seats by adding teammates; you'll be prorated automatically.
IN THIS ARTICLE
* Solo: 1 seat, no integrations.
* Team: per-seat pricing, all integrations.
* Pro: per-seat pricing, custom domain, SSO, audit log.
WHERE TO GO IN THE APP
**Admin → Billing** → **Plan**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Payment method
Add a card during checkout, swap it later from Billing. We use Stripe — your card number never touches Pivotal's database.
IN THIS ARTICLE
* Visa, Mastercard, Amex, Discover.
* Bank debit on annual plans, by request.
* Failed charges retry for 3 days, then suspend the workspace.
WHERE TO GO IN THE APP
**Admin → Billing** → **Payment method**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Invoice access
Every payment generates a PDF invoice. Forward one to finance from the dashboard, or wire up a recipient who gets every invoice by email.
IN THIS ARTICLE
* Past invoices live under **Billing → Invoices**.
* Add a billing-only email under **Billing → Recipients**.
* Custom VAT or PO numbers go on a per-invoice basis — email [help@pivotal.app](mailto:help@pivotal.app).
WHERE TO GO IN THE APP
**Admin → Billing** → **Invoices**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Customers
Customers are the central record in Pivotal — onboardings, contacts, and tasks all hang off them. Ten short reads on adding, editing, and triaging.
WORKING WITH CUSTOMER RECORDS
Manually, via API, or from a CRM sync.
Name, owner, plan, and custom fields.
How the 0–100 score is calculated.
When Pi flags risk and what to do.
Multiple humans per customer.
Roles, primary contact, departures.
Name, email, domain, custom field.
Free-form tags and structured fields.
Activity log per customer.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with the article you're on and a screenshot if you're stuck.
# Add a customer
Three paths in: the **+ Customer** button in the top bar, the REST API (`POST /v1/customers`), or a CRM sync that creates them automatically.
IN THIS ARTICLE
* Required: a name.
* Recommended: domain (used to auto-match contacts) and owner.
* Optional: plan, ARR, custom fields.
WHERE TO GO IN THE APP
**Customers** list → **+ Customer**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Edit customer details
Every field on the customer record is editable inline. Changes write to the audit log so you can see who changed what.
IN THIS ARTICLE
* Click any field on the customer detail page to edit it.
* Custom fields show up under **Details → More**.
* Bulk edits live in the **Customers** list — multi-select, **Edit selected**.
WHERE TO GO IN THE APP
Open a customer → click the field.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Health score
A 0–100 score that summarizes whether a customer is on track. Pi recalculates it nightly and on key events (phase changes, task completions, support tickets).
IN THIS ARTICLE
* 70+ healthy, 40–70 watch, below 40 at-risk.
* Score drops by 10 or more trigger a `customer.health_score_dropped` webhook.
* Components: onboarding velocity, recent activity, contract size, support load.
WHERE TO GO IN THE APP
Open a customer → **Health** tab.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# At risk and churned
At-risk is a flag Pi or a teammate sets when a customer needs attention. Churned is a terminal state — the customer has left.
IN THIS ARTICLE
* At-risk customers surface in the Workbench's risk lane.
* Set or clear it manually from the customer detail page.
* Marking churned moves the customer out of the active list but keeps history.
WHERE TO GO IN THE APP
Open a customer → **Status** dropdown.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Add contacts
Contacts are the humans at a customer. A customer can have any number of contacts; one is marked primary for default routing.
IN THIS ARTICLE
* **+ Contact** on the customer page.
* Bulk paste a CSV of name, email, title.
* Contacts in your CRM with a matching domain auto-link on sync.
WHERE TO GO IN THE APP
Open a customer → **Contacts** tab → **+ Contact**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Manage contacts
Change the primary contact, edit roles, archive someone who left. Archive keeps history; delete removes them. Most teams archive.
IN THIS ARTICLE
* Click a contact to edit name, email, title, role.
* Promote to primary with the star icon.
* Archive when someone leaves the company.
WHERE TO GO IN THE APP
Customer → **Contacts** tab → click a contact.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Search customers
Press ⌘K from anywhere to open Workbench search. Searches go against name, domain, contact emails, and any custom fields you've added.
IN THIS ARTICLE
* ⌘K opens the global search.
* Quoted strings match exactly.
* `field:value` syntax filters by custom field.
WHERE TO GO IN THE APP
⌘K, anywhere in the app.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Tags and metadata
Tags are free-form text. Custom fields are structured (string, number, date, select). Use tags for ad-hoc grouping, custom fields for things you'll filter or report on.
IN THIS ARTICLE
* Tags: type and enter. Reuse by clicking suggestions.
* Custom fields: configure once under **Admin → Custom fields**.
* Both surface in Workbench filters and in the API.
WHERE TO GO IN THE APP
Customer → **Details** → **Tags** or **Custom fields**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Customer history
Every change to a customer — field edits, phase moves, comments, integration syncs — lands in the activity feed. Filter by actor or by event type.
IN THIS ARTICLE
* **Activity** tab on the customer page.
* Filter by actor (you, teammates, integrations, Pi).
* Export to CSV from the tab's **⋯** menu.
WHERE TO GO IN THE APP
Customer → **Activity** tab.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Onboardings
Onboardings are how customers get to value. Pivotal models them as a sequence of phases, each with its own task list.
PHASES, TASKS, AND HANDOFFS
Default phases and how to customize.
Advance, send back, skip.
Setting and shifting the launch date.
Reusable phase + task bundles.
Active, paused, blocked, completed.
Mark complete, undo, bulk.
Break work into smaller pieces.
Inline notes and file uploads.
Who changed what, when.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with the article you're on and a screenshot if you're stuck.
# The phase model
Every onboarding moves through a sequence of phases. The default sequence is Kickoff → Integration → UAT → Launch → Adoption, but each workspace can rename, reorder, or add to it.
IN THIS ARTICLE
* Phases are workspace-wide — every onboarding uses the same sequence.
* Customize from **Admin → Onboarding phases**.
* Each phase has a target duration that Pi uses for at-risk calculations.
WHERE TO GO IN THE APP
**Admin → Onboarding phases**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Move through phases
Drag the onboarding card to the next phase column on the kanban, or use the **Advance phase** button on the detail page. Sending back or skipping is allowed; both write to the audit log.
IN THIS ARTICLE
* Advance: forward one phase.
* Send back: down one phase, requires a reason.
* Skip: jump multiple phases — admins only.
WHERE TO GO IN THE APP
Open the onboarding → **Phase** button.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Target launch dates
The target launch date is Pivotal's source of truth for when an onboarding should go live. Pi forecasts against it; the Workbench's risk lane uses it; the customer can see it in the portal.
IN THIS ARTICLE
* Set on creation or from the detail page.
* Shift it with a reason — Pi tracks slippage.
* Cleared dates show as **No target** in the Workbench.
WHERE TO GO IN THE APP
Onboarding detail → **Target launch** field.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Phase templates
Templates bundle a phase with a default task list. Apply a template at onboarding creation and the tasks land pre-populated.
IN THIS ARTICLE
* Create from **Admin → Templates**.
* Templates can include subtasks, assignees by role, and links.
* Multiple templates per phase — pick at creation time.
WHERE TO GO IN THE APP
**Admin → Templates**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Onboarding states
State is the lifecycle layer on top of phases. Active onboardings count toward team capacity; paused and blocked don't.
IN THIS ARTICLE
* Active: default. Counts toward capacity.
* Paused: customer-side delay. Pi mutes risk alerts.
* Blocked: internal delay. Pi raises priority.
* Completed: terminal.
WHERE TO GO IN THE APP
Onboarding detail → **State** dropdown.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Task completion
Check the box. Pi tracks who completed it and when. Undo is one click for the next 30 days.
IN THIS ARTICLE
* Click the checkbox on the task list, or open the task and click **Complete**.
* Bulk: multi-select tasks, **Mark all complete**.
* Undo from the **Activity** tab on the task.
WHERE TO GO IN THE APP
Onboarding → **Tasks** tab.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Subtasks
Subtasks break a task into checklist items. The parent task auto-completes when every subtask is checked.
IN THIS ARTICLE
* Add from the task detail panel.
* Subtasks are one level deep — no nesting beyond that.
* Reorder by drag.
WHERE TO GO IN THE APP
Open a task → **Subtasks** section.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Comments and attachments
Comments live on tasks and on the onboarding itself. Attachments accept any file up to 25 MB. Both notify the assignee plus anyone @mentioned.
IN THIS ARTICLE
* @mention to notify.
* Drag a file into the comment box to attach.
* Edit or delete your own comments within 24 hours.
WHERE TO GO IN THE APP
Task or onboarding → **Comments** panel.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Audit log
Every change to an onboarding — field edits, phase moves, task completions, comments — lands in the audit log. Filter by actor or by event type.
IN THIS ARTICLE
* **Audit** tab on the onboarding page.
* Filter by actor or by event type.
* Workspace-wide audit log lives on Pro plans.
WHERE TO GO IN THE APP
Onboarding → **Audit** tab.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Workbench
The Workbench is what teammates open first thing in the morning — risk lane, today's tasks, recent activity, and Pi's daily summary, all on one page.
YOUR TODAY VIEW
⌘J from anywhere.
Pin customers and onboardings.
Add, remove, reorder widgets.
Built-in views over onboardings, tasks, and risk.
Workspace-wide stream.
⌘K opens global search.
Save a filter as a view.
Stick something to the top.
Daily streak and demo workspace.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with the article you're on and a screenshot if you're stuck.
# Open the Workbench
⌘J from anywhere opens the Workbench. The same shortcut closes it.
IN THIS ARTICLE
* ⌘J open and close.
* Lives at `my.pivotal.app/workbench` if you'd rather bookmark.
* First panel on load is your assigned tasks for today.
WHERE TO GO IN THE APP
⌘J anywhere.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Favorites
Star a customer or onboarding to pin it to your sidebar. Favorites are per-teammate.
IN THIS ARTICLE
* Click the star on the customer or onboarding header.
* Drag to reorder in the sidebar.
* Up to 12 favorites; beyond that, pin one to swap.
WHERE TO GO IN THE APP
Customer or onboarding page → star icon.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Customize your dashboard
Drag widgets around. Add new ones from the **+ Widget** button. Reset to default if you want to start over.
IN THIS ARTICLE
* Widgets: Tasks, Risk lane, Activity, Pi summary, Reports.
* Resize by dragging the bottom-right corner.
* **Reset** button under **⋯** to revert.
WHERE TO GO IN THE APP
Workbench → **⋯** → **Customize**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Reports
Pre-built reports over onboardings, tasks, and risk. Pin a report to your Workbench for the at-a-glance view.
IN THIS ARTICLE
* Onboardings by phase, by owner, by status.
* Tasks: open, overdue, completed this week.
* Risk: customers and onboardings at risk.
WHERE TO GO IN THE APP
Workbench → **Reports** widget → click a report.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Activity feed
A workspace-wide stream of every action — phase changes, task completions, comments, integration syncs. Filter by actor, by customer, or by event type.
IN THIS ARTICLE
* Default scope: your assigned customers.
* Switch to workspace-wide from the dropdown.
* Comments and @mentions highlight in the feed.
WHERE TO GO IN THE APP
Workbench → **Activity** widget.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Search
⌘K opens global search. Type a few characters and Pivotal narrows in real time across customers, onboardings, tasks, and contacts.
IN THIS ARTICLE
* Arrow keys to move, Enter to open.
* `@me` filters to your assignments.
* `type:onboarding` narrows to a specific resource.
WHERE TO GO IN THE APP
⌘K anywhere.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Filters and views
Filters narrow any list — Customers, Onboardings, Tasks. Save a filter combo as a view to come back to it later.
IN THIS ARTICLE
* Multi-select filters: stack as many as you need.
* **Save view** to keep the filter set.
* Views are per-teammate by default; mark public to share.
WHERE TO GO IN THE APP
List page → **Filter** button → **Save view**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Pinned items
Pin a task or comment to stick it to the top of its parent. Useful for the one thing the team should always see first.
IN THIS ARTICLE
* Pin from the **⋯** menu on the item.
* Up to 3 pinned items per onboarding.
* Pinned items show a 📌 in lists.
WHERE TO GO IN THE APP
Hover an item → **⋯** → **Pin**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Streak and sample data
The streak counter ticks up for every day you complete at least one task. Sample data is a separate demo workspace you can switch into to try things without touching production.
IN THIS ARTICLE
* Streak resets at midnight local time if you don't complete anything.
* Switch to **Sample workspace** from the workspace dropdown.
* Sample workspace is shared across your team but read-only on shared records.
WHERE TO GO IN THE APP
Workbench header → streak counter and workspace dropdown.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Ask Pi
Pi is Pivotal's AI layer. It summarizes, forecasts, drafts comments, and flags risk. Pi runs on top of your workspace data; it doesn't see anything outside it.
PIVOTAL'S BUILT-IN AI
The catalog of Pi actions.
Tone, depth, voice.
Prompts the team comes back to.
Triage by risk.
Daily digest.
Hit-or-miss for target dates.
Match your team voice.
What Pi sees and what it doesnt.
What Pi gets wrong.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with the article you're on and a screenshot if you're stuck.
# What Pi can do
Pi answers questions about your workspace, drafts comments and emails, summarizes, and forecasts. It does not edit records or send messages without a teammate confirming first.
IN THIS ARTICLE
* Read: any customer, onboarding, task, contact you can see.
* Draft: comments, emails, summaries.
* Forecast: launch dates, risk, capacity.
* Never: writes without confirmation, sees data outside your workspace.
WHERE TO GO IN THE APP
⌘I anywhere — or the Pi tab in the sidebar.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Pi preferences
Per-teammate settings that shape how Pi talks back. Default is concise and direct; switch to detailed if you want longer answers.
IN THIS ARTICLE
* Tone: direct, friendly, formal.
* Depth: concise, standard, detailed.
* Voice: see [Voice profile](/product/ask-pi/voice-profile).
WHERE TO GO IN THE APP
**Account → Pi preferences**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Useful prompts
The prompts the team comes back to. Save your own as quick prompts from the Pi composer.
IN THIS ARTICLE
* "What changed since yesterday across my customers?"
* "Forecast launch for Acme Corp."
* "Draft a check-in email for everyone in UAT this week."
* "Who hasn't responded in 7+ days?"
WHERE TO GO IN THE APP
Pi composer → **Quick prompts**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# What's at risk
Pi ranks customers and onboardings by risk. The risk lane on the Workbench is this exact ranking.
IN THIS ARTICLE
* Top of the lane: customers with multiple risk signals.
* Click an entry to see why Pi flagged it.
* Dismiss a flag if it's noise — Pi learns from the dismissal.
WHERE TO GO IN THE APP
Workbench → **Risk lane** widget.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Summarize today
A daily digest of what changed across your assigned customers — completed tasks, phase moves, comments, new risk flags. Delivered to your inbox at 7 a.m. and pinned to the Workbench.
IN THIS ARTICLE
* Toggle the email on or off in Pi preferences.
* The Workbench version is always on.
* Click any line item to jump to the source record.
WHERE TO GO IN THE APP
Workbench → **Pi summary** widget.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Forecast launches
Pi estimates each onboarding's actual launch date by reading task pace, phase velocity, and historical slippage. The estimate shows beside the target on every onboarding detail page.
IN THIS ARTICLE
* Forecast updates nightly and on phase change.
* Slippage shows in days, positive or negative.
* Pi highlights the contributing factors when you click the estimate.
WHERE TO GO IN THE APP
Onboarding detail → **Forecast** chip.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Voice profile
Pi can match your team's voice. Paste 3–5 examples of comments you've written and Pi calibrates tone, sentence length, and word choice.
IN THIS ARTICLE
* Examples are workspace-shared by default.
* Pi quotes from a sample when drafting, never copies verbatim.
* Reset anytime to drop the calibration.
WHERE TO GO IN THE APP
**Account → Pi preferences → Voice profile**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Privacy
Pi reads your workspace data over an encrypted channel. We don't train on your data and we don't share it with other workspaces.
IN THIS ARTICLE
* Pi runs on Anthropic's API with zero-retention enabled.
* No training. No cross-workspace context.
* Per-teammate audit log of every Pi query lives under **Admin → Audit log**.
WHERE TO GO IN THE APP
**Admin → Audit log → Pi queries**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Limitations
Pi is good at summarizing and forecasting, less good at counting. Treat numeric claims as a starting point and double-check anything that drives a decision.
IN THIS ARTICLE
* Pi doesn't always cite. Ask for sources if you need them.
* Forecasts have wider error bars in the first 30 days of an onboarding.
* Pi won't run a destructive action even if you ask.
WHERE TO GO IN THE APP
Hover any Pi answer → **Sources** chip.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Integrations
Pivotal connects to the systems your team already runs on — CRMs, billing, and messaging. Per-integration setup, plus the patterns that work across all of them.
CONNECT TO THE TOOLS YOU ALREADY USE
Mirror companies and contacts.
Pull customers and revenue.
Notifications and the Pivotal bot.
Map your fields to Pivotals.
Why a record didnt come over.
Slack, email, and in-app.
Reusable customer comms.
Subscribe to events.
CSV and full takeout.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with the article you're on and a screenshot if you're stuck.
# Connect HubSpot
OAuth into HubSpot and pick which company lists mirror into Pivotal. The sync runs nightly plus on demand from the integration page.
IN THIS ARTICLE
* Admin only: HubSpot connections live at the workspace level.
* Pick lists to mirror — Pivotal doesn't pull every company by default.
* Two-way sync for selected fields; HubSpot stays source of truth.
WHERE TO GO IN THE APP
**Admin → Integrations → HubSpot**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Connect Stripe
Read-only connection to Stripe. Pivotal pulls customers, subscriptions, and MRR so health score and reports stay in step with what's billed.
IN THIS ARTICLE
* Stripe restricted key with read scope on Customers and Subscriptions.
* No writes to Stripe.
* New Stripe customers appear in Pivotal within an hour.
WHERE TO GO IN THE APP
**Admin → Integrations → Stripe**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Connect Slack
Two pieces: outbound notifications (Pivotal posts to channels you pick) and the Pivotal bot (your team queries Pivotal from Slack).
IN THIS ARTICLE
* Notifications: per-channel routing rules.
* Bot: `@Pivotal` mentions and slash commands.
* Per-teammate DMs toggle in **Account → Notifications**.
WHERE TO GO IN THE APP
**Admin → Integrations → Slack**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Field mapping
Match fields in your CRM to Pivotal's customer fields. Pivotal ships sensible defaults; tweak the mapping if your CRM uses different names.
IN THIS ARTICLE
* Defaults: name, domain, owner email, ARR, plan.
* Custom fields land under **Details → More**.
* Unmapped fields stay in your CRM only.
WHERE TO GO IN THE APP
**Admin → Integrations → \[provider] → Mapping**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Troubleshoot sync
A record didn't come over. Three things to check, in order.
IN THIS ARTICLE
* Is the record in the mirrored list or segment? Check the source.
* Does the record match the filter? (e.g. closed-won deals only)
* Did the sync fail? **Integrations → \[provider] → Sync log** shows errors.
WHERE TO GO IN THE APP
**Admin → Integrations → \[provider] → Sync log**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Notifications
Three channels — Slack, email, and in-app. Each event type can route to a different combo, per teammate and per workspace.
IN THIS ARTICLE
* Workspace defaults under **Admin → Notifications**.
* Per-teammate overrides under **Account → Notifications**.
* @mentions always notify regardless of routing.
WHERE TO GO IN THE APP
**Account → Notifications**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Email templates
Reusable email drafts. Pick a template from the customer page and Pi fills the merge fields with the customer's data.
IN THIS ARTICLE
* Create from **Admin → Templates → Email**.
* Merge fields: `{{customer.name}}`, `{{onboarding.target_launch}}`, more.
* Pi can draft a one-off based on a template.
WHERE TO GO IN THE APP
**Admin → Templates → Email**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Webhooks
Subscribe to events from Pivotal so your own systems can react. Full developer documentation is in the [Webhooks](/webhooks/overview) product.
IN THIS ARTICLE
* Twenty event types across customer, contact, onboarding, task.
* Signed deliveries, 24h retries, replay button.
* Test mode endpoints for staging.
WHERE TO GO IN THE APP
**Admin → Developer → Webhooks**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.
# Data export
Pull your workspace data out anytime. Two options: a CSV per resource list, or a full JSON takeout of every record.
IN THIS ARTICLE
* CSV: any list page → **⋯** → **Export CSV**.
* Full takeout: **Admin → Data → Request takeout**.
* Takeout delivers a download link by email within an hour.
WHERE TO GO IN THE APP
**Admin → Data → Request takeout**.
Email **[help@pivotal.app](mailto:help@pivotal.app)** with a screenshot of where you got stuck and the customer or onboarding id from the URL.