This is the abridged developer documentation for pdcli
# pdcli
> A fast, scriptable, open-source CLI for Pipedrive — built for terminals, CI pipelines, and AI agents.
**v0.18.0 · 145 commands · 26 topics Drive the whole\ Pipedrive pipeline\ from your terminal. =================== One install. Every object. `pdcli` turns deals, people, activities, and reports into fast, pipeable commands — for your scripts, your CI, and your agents. [Get started →](/pdcli/start/quickstart/) [Browse 145 commands](/pdcli/reference/commands/) ******zsh · \~/acme*live*
```
❯ ▋
```
A scriptable building block for CI pipelines cron jobs Slack bots data exports AI agents internal tooling QUICKSTART ## Three commands to your first deal Install, authenticate, and create — you'll be driving your pipeline from the shell in under a minute. 01 ### Install $ `npm install -g @wavyx/pdcli` Node 20+. Tarballs on GitHub Releases for everything else. 02 ### Authenticate $ `pdcli auth login` Prompts for your API token — stored in your OS keychain. 03 ### Create $ `pdcli deal create --title "Acme" --value 5000` Your first deal, straight from the prompt. [Read the full setup guide ](/pdcli/start/installation/) Why pdcli ## A CLI that respects your workflow Predictable, composable, and quiet by default — designed to slot into scripts and disappear into your muscle memory. ### Backup & data export Stream your entire account to JSON with pdcli backup --resume, or export any resource to CSV. Resumable, scriptable, and perfect for archives or migrations. ### Health reports pdcli pipeline health and pdcli audit --strict surface stale deals, missing fields, and data drift — so you can keep your Pipedrive clean, accurate, and trustworthy. ### Built for AI agents Self-documenting --help on every command and clean JSON output make pdcli easy for agents to discover and drive — a ready-made tool surface for your LLM workflows. ### Scriptable by design Stable flags, deterministic exit codes, and machine-readable output. Pipe it, branch on it, schedule it. ### JSON, jq & tables Switch between human tables and JSON with one flag, then shape results inline with built-in --jq. ### API token or OAuth Start in seconds with an API token, or run the full OAuth flow when you need it. Either way pdcli stores credentials in your OS keychain, never plaintext on disk. IN PRACTICE ## Close deals from the command line. Track your pipeline, manage deals, and log activities without leaving your shell — so you can spend less time clicking and more time selling. `pdcli` is a fast, scriptable companion for the Pipedrive API: 145 commands across 26 topics. $ npm install -g @wavyx/pdcli * Free & open source * macOS, Linux & Windows * API token or OAuth login * Works with your existing account ******\~/acme — pd
```
$ pdcli deal list --status open --limit 4
ID TITLE VALUE STAGE
4821 Acme renewal €12,000 Negotiation
4774 Northwind expansion € 8,500 Proposal
4710 Globex pilot € 4,200 Qualified
4699 Initech seats € 2,750 Contact
4 open deals · €27,450 open value
$ pdcli deal update 4821 --status won
✓ Acme renewal → Won · activity logged
$ ▋
```
145 commands 26 topics 4 output formats 100% test coverage SIGNATURE MOVES ## One-liners that replace a dashboard Learn one pattern, run everything — real commands, copy-paste ready. This is what living in `pdcli` feels like. `pdcli [target] [flags]` Pipeline health `pdcli pipeline health --pipeline 1` Import a CSV `pdcli person import leads.csv --dry-run` Bulk update via pipe `pdcli deal list --status open --jq '.[].id' | pdcli deal bulk-update --stage 5` Audit as a CI gate `pdcli audit --strict` Escape hatch `pdcli api GET /api/v1/currencies` ## Less time managing, more time closing. Install in seconds, automate the rest. Your pipeline, one command away. [Get started](/pdcli/start/quickstart/) [Command reference](/pdcli/reference/commands/)
# CI recipes
> Run pdcli in CI with env-var auth, nightly backups, audit gates, bulk imports, and deterministic flags.
`pdcli` is built for non-interactive use. In CI, authenticate with environment variables (no keychain, no prompts), and use deterministic flags so a run either succeeds or fails cleanly. ## Authenticate with env vars [Section titled “Authenticate with env vars”](#authenticate-with-env-vars) In CI, set the company domain and a personal API token as environment variables instead of running `pdcli auth login`. The token never touches disk and stays out of shell history.
```bash
PDCLI_COMPANY_DOMAIN=acme PDCLI_API_TOKEN=xxxxxxxx pdcli deal list
```
Store the token as an encrypted secret and inject it per step. In GitHub Actions:
```yaml
name: pipedrive-audit
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
env:
PDCLI_COMPANY_DOMAIN: acme
PDCLI_API_TOKEN: ${{ secrets.PIPEDRIVE_API_TOKEN }}
steps:
- uses: actions/setup-node@v5
with:
node-version: 20
- run: npm install -g @wavyx/pdcli
- run: pdcli audit --strict
```
Env auth means no OS keychain is required on the runner — env vars take precedence over the keychain, so writes (login) are never attempted. See [Security model](/pdcli/concepts/security/). ## Deterministic flags [Section titled “Deterministic flags”](#deterministic-flags) CI runs should be predictable. Two flags matter most: * `--no-retry` — disable automatic backoff on `429` and `5xx`. The command fails immediately with exit `75` (rate limited) or `69` (service unavailable) instead of sleeping through a retry window. Use it when you'd rather fail fast and let the CI scheduler retry the job. * `--timeout ` — cap each request (default 30000). A hung network won't stall the job past your budget.
```bash
pdcli deal list --no-retry --timeout 10000 --output json
```
Piped output defaults to JSON, so `pdcli ... | jq` works without `--output json`. Errors go to stderr as JSON; see [Exit codes](/pdcli/automation/exit-codes/). ## Audit as a data-quality gate [Section titled “Audit as a data-quality gate”](#audit-as-a-data-quality-gate) `pdcli audit` runs 11 hygiene checks. With `--strict` it exits `1` when any **must-severity** check has findings (stale deals, duplicate persons, missing fields, …), which fails the job.
```bash
pdcli audit --strict
# narrow the gate to specific checks:
pdcli audit --checks duplicate-persons,missing-fields --strict
```
Exit `1` = the gate caught something. Wire it as a required check on a schedule, or before a deploy that depends on clean data. See [Data-hygiene audit](/pdcli/guides/audit/). ## Nightly backup [Section titled “Nightly backup”](#nightly-backup) Export the whole account to a JSON tree on a schedule. `--resume` skips resources already written, so a re-run after a transient failure continues instead of restarting.
```yaml
jobs:
backup:
runs-on: ubuntu-latest
env:
PDCLI_COMPANY_DOMAIN: acme
PDCLI_API_TOKEN: ${{ secrets.PIPEDRIVE_API_TOKEN }}
steps:
- uses: actions/setup-node@v5
with: { node-version: 20 }
- run: npm install -g @wavyx/pdcli
- run: pdcli backup --dir ./pipedrive-backup
- uses: actions/upload-artifact@v4
with:
name: pipedrive-backup
path: ./pipedrive-backup
```
`backup` paces itself against the token budget, so it's safe to run alongside other jobs. ## Incremental pulls [Section titled “Incremental pulls”](#incremental-pulls) For a cheap scheduled sync, pull only what changed since the last run rather than the whole account. The list commands take `--updated-since` (RFC3339, no fractional seconds), and `--limit 500` keeps each page at the maximum so you make the fewest calls:
```bash
pdcli deal list --updated-since 2026-06-01T00:00:00Z --limit 500 --output json
```
Pass the previous run's timestamp on each schedule tick and you sync deltas instead of the full set. (`product list` supports `--updated-since` only — products have no `--updated-until`.) If the **daily** token budget runs out, pdcli fails fast: a `429` carrying `x-daily-ratelimit-token-remaining: 0` is not retried (backoff would stall until the daily reset at midnight server time), so the command exits `75` immediately with a clear `Daily API token budget exhausted` message. Add `--verbose` to log the remaining daily budget after each request, so a job can warn before it hits the wall. ## Bulk import in a pipeline [Section titled “Bulk import in a pipeline”](#bulk-import-in-a-pipeline) Validate CSV rows first with `--dry-run`, then import with `--yes` to skip the confirmation prompt (there's no TTY in CI):
```bash
pdcli person import people.csv --dry-run # fails (exit 65) on bad rows
pdcli person import people.csv --yes # creates rows
```
A failed `--dry-run` exits non-zero, so chaining with `&&` stops the import if validation fails. CSV headers map to fields, custom fields by human name. See [Bulk operations & CSV import](/pdcli/guides/bulk/).
# Cookbook
> Copy-paste pdcli recipes for common Pipedrive tasks — reports, bulk edits, exports, and cron jobs.
Each recipe is one command or pipeline. They assume you're authenticated (or have `PDCLI_COMPANY_DOMAIN` + `PDCLI_API_TOKEN` set). Piped commands emit JSON automatically, so `--jq` and `jq` work without `--output json`. ## Stale open deals to CSV [Section titled “Stale open deals to CSV”](#stale-open-deals-to-csv)
```bash
pdcli audit --checks stale-deals --output json \
| jq -r '.[0].items[] | [.id, .title, .days] | @csv' > stale-deals.csv
```
Writes one row per open deal untouched for more than 14 days, with the age in days. ## Bulk stage-move via a saved filter [Section titled “Bulk stage-move via a saved filter”](#bulk-stage-move-via-a-saved-filter)
```bash
pdcli deal bulk-update --filter 9 --stage 5 --dry-run # preview targets first
pdcli deal bulk-update --filter 9 --stage 5 # confirms, then moves
```
`--dry-run` lists every deal the saved filter selects without writing. Drop it (or add `--yes`) to apply. Partial failures are reported per deal and exit `1`. ## Weekly pipeline-health JSON for a dashboard [Section titled “Weekly pipeline-health JSON for a dashboard”](#weekly-pipeline-health-json-for-a-dashboard)
```bash
pdcli pipeline health --pipeline 1 --output json > health-$(date +%F).json
```
Per-stage open count, total value, probability-weighted value, stale-deal count, and deals with no next step. Pass `--pipeline` when the account has more than one pipeline. ## Find a person by email [Section titled “Find a person by email”](#find-a-person-by-email)
```bash
pdcli search "jane@acme.com" --item-types person --output json \
| jq '.[] | {id, name}'
```
Add `--exact` for an exact match. ### Scoped vs. multi-type search routing [Section titled “Scoped vs. multi-type search routing”](#scoped-vs-multi-type-search-routing) `search` routes by how many item types you ask for: * **A single routable type** — `--item-types deal`, `person`, `organization`, or `product` — hits that entity's dedicated v2 search endpoint (e.g. `/api/v2/persons/search`), which needs only that entity's OAuth scope and accepts entity-specific server-side filters. Its `--limit` is capped at 100 (the endpoint rejects more). * **Anything else** — no `--item-types`, multiple types, or a non-routable type like `lead`, `file`, or `project` — stays on the generic `itemSearch`. Both search endpoints cost the same 20 rate-limit tokens. When the scope is a single **deal** search, three filters narrow it server-side: `--status` (`open`/`won`/`lost`), `--person` (a person ID), and `--org` (an organization ID). These are rejected (exit 64) with any other scope, since only `/api/v2/deals/search` accepts them:
```bash
pdcli search "renewal" --item-types deal --status open --org 7 --output json
```
## Create a deal with custom fields in one line [Section titled “Create a deal with custom fields in one line”](#create-a-deal-with-custom-fields-in-one-line)
```bash
pdcli deal create --title "Acme renewal" --value 5000 --currency EUR \
--stage 3 --field "Deal Size=Large" --field "Score=4.5"
```
Custom fields are given by **human name**; pdcli resolves the 40-char hash key and maps option labels (like `Large`) to their IDs. See [Custom fields](/pdcli/guides/custom-fields/). ## Nightly cron backup [Section titled “Nightly cron backup”](#nightly-cron-backup)
```bash
# crontab -e
0 2 * * * PDCLI_COMPANY_DOMAIN=acme PDCLI_API_TOKEN=xxxx \
/usr/local/bin/pdcli backup --dir /backups/pipedrive --resume
```
Full-account export to a JSON tree. `--resume` skips resources finished in a prior run, so a re-run after an interruption continues instead of restarting. ## Duplicate-persons report [Section titled “Duplicate-persons report”](#duplicate-persons-report)
```bash
pdcli audit --checks duplicate-persons --verbose
```
Lists every email shared by more than one person, with the person IDs, so you can merge them. Output (table mode, `--verbose`):
```text
Sev Check Findings
● Persons sharing the same email 3
Persons sharing the same email
{"email":"jane@acme.com","ids":[12,87]}
…
```
## Export persons for a mail merge [Section titled “Export persons for a mail merge”](#export-persons-for-a-mail-merge)
```bash
pdcli person list --output csv --fields id,name,email > contacts.csv
```
`--fields` selects columns; `--output csv` produces a header row plus one row per contact. Scope it with `--org ` or `--owner `. ## Log an activity after a call [Section titled “Log an activity after a call”](#log-an-activity-after-a-call)
```bash
pdcli activity create --subject "Discovery call" --type call \
--due-date 2026-06-04 --done --deal 42 --note "Budget confirmed, sending quote"
```
`--done` marks it complete on creation; `--deal` links it. On v2 activity writes `--person` maps to a primary participant. ## Won-deal report for the quarter [Section titled “Won-deal report for the quarter”](#won-deal-report-for-the-quarter)
```bash
pdcli deal list --status won --output json \
| jq '[.[] | select(.won_time >= "2026-04-01")]
| {count: length, total: (map(.value) | add)}'
```
Counts deals won since the quarter start and sums their value. For the full sales-velocity breakdown over a trailing window, use `pdcli metrics velocity --period 90d`.
# Exit codes
> Deterministic sysexits exit codes pdcli returns, what triggers each, and how a script should react.
`pdcli` returns deterministic [sysexits](https://man.freebsd.org/cgi/man.cgi?query=sysexits) exit codes so scripts and agents can branch on the failure without parsing text. Every command shares the same ladder. ## The codes [Section titled “The codes”](#the-codes) | Code | Meaning | Pipedrive trigger | What a script should do | | ---- | ------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | Success | 2xx | Continue. | | `1` | Generic error | — | Inspect the message; safe to surface and stop. `audit --strict` returns 1 on must-severity findings; a partial bulk/import failure returns 1 when some rows failed for non-data reasons. | | `64` | Usage / bad input | — | A bad invocation: an unknown flag or a missing/invalid argument (caught by the parser), or a value pdcli validated and rejected (invalid `--period`, unknown `--checks` name, several pipelines with no `--pipeline`, a CSV missing its name column, `--ids` over 100). Fix the invocation — retrying won't help. | | `65` | Data / validation | `400`, `422` | The request body or input data was rejected: a bad field value, a non-numeric id cell in a CSV, an ambiguous upsert match, malformed `--body` JSON, or a conversion the API rejected. Fix the payload; don't retry unchanged. | | `69` | Service unavailable | `5xx`, network | Pipedrive is down/erroring, **or the host is unreachable** (DNS failure, connection refused, or a `--timeout`). pdcli already retried with backoff; safe to retry later. | | `70` | Internal CLI bug | — | An unexpected error inside pdcli — and nothing else (network, usage, and API failures all map elsewhere). File an issue; retrying won't help. | | `75` | Rate limited | `429` | Token budget exhausted. pdcli retries 429s with backoff; if they never clear it surfaces `75` (not `69`), and `--no-retry` surfaces the first one. Back off and retry after the reset window. | | `77` | Auth / permission | `401`, `403` | Token is invalid, expired, or lacks scope — including a **failed OAuth refresh** (`invalid_grant`). Re-authenticate (`pdcli auth login`). Don't loop. A `403` after repeated `429`s is a rate-limit hard stop — wait for the reset, don't retry. | | `78` | Config / account | `402`, missing domain/token, host-lock violation, no keychain | The CLI or account is misconfigured: no company domain, no keychain to write to, a `pdcli api` URL outside your host, a redirect off your host, or a `402` (plan lacks the feature). Fix config or the account — don't retry. | | `8` | Findings present (`watch` only) | — | `pdcli watch` exits `8` when it surfaces new anomalies, so `pdcli watch \|\| notify` fires only on findings. Not part of the general ladder — specific to `watch`. | The HTTP-status mapping lives in `src/lib/errors.js` (`exitCodeForStatus`): `400/422 → 65`, `401/403 → 77`, `402 → 78`, `429 → 75`, `5xx → 69`. On top of that: an oclif parse error (unknown flag, missing/invalid argument) exits `64`; an unreachable host or timeout exits `69`; and only a genuinely unexpected throw exits `70`. ## Error output [Section titled “Error output”](#error-output) The error format **mirrors the success output format** (the same `--output` / `default_output` / TTY rule). Whenever output is **not** an interactive table — an explicit `--output json|yaml|csv`, a non-table `default_output` in the profile, **or stdout piped** — the error is written to **stderr** as a single JSON object, so a machine consumer that gets JSON on success always gets a parseable failure. stdout stays clean so a successful-looking pipe can't swallow a failure:
```json
{
"error": "ApiError",
"message": "Pipedrive API 422: Deal title must not be empty",
"exitCode": 65,
"statusCode": 422,
"path": "/api/v2/deals",
"errorInfo": "Please provide a valid title"
}
```
`error` is the error class name. `statusCode`, `path`, and `errorInfo` appear only for API errors (they're omitted for usage/config errors). Under `--verbose` the full Pipedrive `body` is added too. Only an **interactive table** context prints the message to stderr in human form; `--verbose` there adds the request path, status code, and `error_info`. ## Branching on the code [Section titled “Branching on the code”](#branching-on-the-code)
```bash
pdcli deal get 42 --output json > deal.json
case $? in
0) echo "ok" ;;
64) echo "bad invocation — fix flags/args" ; exit 1 ;;
65) echo "bad request — not retrying" ; exit 1 ;;
75|69) echo "transient — retry later" ; exit 1 ;;
77) echo "re-auth needed" ; pdcli auth status ; exit 1 ;;
78) echo "config/account problem" ; pdcli doctor ; exit 1 ;;
*) echo "failed with $?" ; exit 1 ;;
esac
```
`pdcli` also prints `127` (via the command-not-found hook) when you invoke a command name that doesn't exist. See also: [Troubleshooting](/pdcli/reference/troubleshooting/) for what to do per failure, and [How pdcli talks to Pipedrive](/pdcli/concepts/api-model/) for the retry/backoff rules.
# Output & filtering
> Choose a format, filter with jq, pick columns, and build scriptable pipelines.
Every command accepts the same output flags: `--output`, `--jq`, `--fields`, and `--no-color`. They work the same whether you're reading a list or a single record. ## Output formats [Section titled “Output formats”](#output-formats)
```bash
pdcli deal list --output table # default in a terminal
pdcli deal list --output json # default when piped
pdcli deal list --output yaml
pdcli deal list --output csv
```
**The default depends on the destination:** a TTY gets a `table`, a pipe or capture gets `json`. So `pdcli deal list` shows a table to you, while `pdcli deal list | cat` emits JSON automatically. Set a persistent default with `pdcli config set default_output json` (see [configuration](/pdcli/guides/configuration/)). Table output:
```text
┌────┬──────────────┬──────────┬────────┬───────┬────────┬─────┬───────┐
│ ID │ Title │ Value │ Status │ Stage │ Person │ Org │ Owner │
├────┼──────────────┼──────────┼────────┼───────┼────────┼─────┼───────┤
│ 42 │ Acme renewal │ 5000 EUR │ open │ 3 │ 17 │ 7 │ 1 │
└────┴──────────────┴──────────┴────────┴───────┴────────┴─────┴───────┘
```
## Filter with `--jq` [Section titled “Filter with --jq”](#filter-with---jq) `--jq` runs a [jq](https://jqlang.github.io/jq/) expression over the JSON result. The native jq binary is bundled and loaded only when you actually use the flag. Lists arrive as arrays (index with `.[]`); single records arrive as the bare object, so a `get` filters directly:
```bash
pdcli deal list --jq '.[].id'
pdcli deal list --jq '.[] | {id, title, value}'
pdcli deal list --status won --jq '[.[].value] | add'
pdcli deal get 42 --jq '.title'
```
(Before 0.9, single records were wrapped in a one-element array and needed `.[0]` — that indirection is gone.) `--jq` overrides `--output` and `--fields` — you get exactly what the expression produces. ## Pick columns with `--fields` [Section titled “Pick columns with --fields”](#pick-columns-with---fields) `--fields` limits a `table` or `csv` to the columns you name (by their data key, not the header):
```bash
pdcli deal list --fields id,title,value
```
```text
┌────┬──────────────┬──────────┐
│ ID │ Title │ Value │
├────┼──────────────┼──────────┤
│ 42 │ Acme renewal │ 5000 EUR │
└────┴──────────────┴──────────┘
```
## Disable color [Section titled “Disable color”](#disable-color) `--no-color` (or setting the `NO_COLOR` environment variable to any value) strips ANSI color — useful in logs and CI where escape codes are noise:
```bash
pdcli deal list --no-color
```
## Recipes [Section titled “Recipes”](#recipes) ### CSV export with chosen columns [Section titled “CSV export with chosen columns”](#csv-export-with-chosen-columns)
```bash
pdcli person list --output csv --fields id,name,email > contacts.csv
```
```text
ID,Name,Email
17,Jane Doe,jane@acme.com
21,John Roe,john@globex.com
```
Values containing commas, quotes, or newlines are quoted and escaped automatically. ### jq + xargs pipeline [Section titled “jq + xargs pipeline”](#jq--xargs-pipeline) Pull IDs and act on each. Because the output is piped, JSON is implied, but `--jq` makes the shape explicit:
```bash
pdcli deal list --status open --owner 42 --jq '.[].id' \
| xargs -I{} pdcli deal get {} --output json
```
You can also stream IDs straight into a bulk command:
```bash
pdcli deal list --status open --jq '.[].id' | pdcli deal bulk-update --owner 42
```
### YAML for humans [Section titled “YAML for humans”](#yaml-for-humans) YAML is the friendliest format for eyeballing a single nested record:
```bash
pdcli deal get 42 --output yaml
```
```text
id: 42
title: Acme renewal
value: 5000
currency: EUR
status: open
stage_id: 3
```
## Resolving custom fields in machine output [Section titled “Resolving custom fields in machine output”](#resolving-custom-fields-in-machine-output) By default `json`, `yaml`, and `csv` keep custom-field values raw — hash keys and numeric option IDs — so scripts have a stable shape to parse. On single-record `get` commands you can opt into readable names with `--resolve-fields`, which swaps hash keys for field names and option IDs for labels. See [Custom fields](/pdcli/guides/custom-fields/) for the details. ## See also [Section titled “See also”](#see-also) * [Quickstart for AI agents](/pdcli/start/agents/) — JSON defaults and exit codes. * [Profiles & configuration](/pdcli/guides/configuration/) — `default_output`.
# How pdcli talks to Pipedrive
> The dual API, the two paginators, and the token-budget rate limiting pdcli handles for you.
`pdcli` wraps Pipedrive's REST API. Most of this is handled for you, but knowing the model explains why some commands behave the way they do. ## Dual API: v2 and v1 [Section titled “Dual API: v2 and v1”](#dual-api-v2-and-v1) Pipedrive runs two API versions. `pdcli` routes each topic to the right one. | Topic | API | | ------------------------------------------------------------------------------------------------ | ------ | | deals, persons, organizations, products, pipelines, stages, activities, projects, fields, search | **v2** | | leads, notes, files, filters, webhooks, goals, users | **v1** | Core CRM is on **v2** because Pipedrive deprecated \~59 v1 core endpoints after 2025-12-31, so pdcli never builds core CRUD on v1. The v1-only topics above have no v2 equivalent yet and are unaffected. Notably, **`pdcli user me` is v1-only** — `/api/v2/users/me` 404s into the web app's HTML page, so the Users API stays on v1. A few v2 specifics that surface in commands: * Updates use **PATCH**, not PUT — only the fields you pass change (this is why update commands say "only provided fields change"). * v2 accepts **JSON bodies only**. * v2 custom-field values are nested under a `custom_fields` object on the entity. Hit anything not yet wrapped with the host-locked [`pdcli api`](/pdcli/guides/api/) escape hatch (`pdcli api GET /api/v2/deals`, `pdcli api GET /api/v1/currencies`). ## Two paginators, never mixed [Section titled “Two paginators, never mixed”](#two-paginators-never-mixed) `list` commands auto-page through every result, capped by `--limit`. The two API versions paginate differently, and pdcli keeps their state separate: * **v2 — cursor.** Sends `cursor` + `limit`, follows `additional_data.next_cursor` until it's null. * **v1 — offset.** Sends `start` + `limit`, follows `additional_data.pagination.next_start` / `more_items_in_collection` until done. The maximum `limit` is **500** for both; pdcli clamps anything higher to 500. (Search is separately capped — itemSearch tops out at 100.) Offset and cursor state are never shared. ## Token-budget rate limiting [Section titled “Token-budget rate limiting”](#token-budget-rate-limiting) Pipedrive doesn't rate-limit by requests-per-second; it spends from a **token budget**. The cost depends on the verb: | Operation | Token cost | | ------------------ | ---------- | | GET one | 2 | | GET list | 20 | | POST / PUT / PATCH | 10 | | DELETE one | 6 | | DELETE list | 10 | | Search | 40 | There's a rolling **2-second burst window** plus a **daily budget** (scales with plan and seats, resets at midnight server time). On a `429`, pdcli reads `x-ratelimit-reset` (falling back to `Retry-After`, then a 2s default) to decide how long to wait before retrying. ### Backoff and the hard stop [Section titled “Backoff and the hard stop”](#backoff-and-the-hard-stop) On a `429`, pdcli waits for the window indicated by `x-ratelimit-reset` (falling back to `Retry-After`, then a 2s default) and retries with exponential backoff. `5xx` responses are also retried with backoff. If Pipedrive escalates persistent rate-limit abuse from `429` to a **`403`**, pdcli treats it as a **hard stop** — it does not retry into it, and exits `77`. Wait for your budget to reset before trying again. `--no-retry` turns off all of this: a `429` becomes an immediate exit `75`, and a `5xx` becomes exit `69`, with no sleeping. Use it in [CI](/pdcli/automation/ci/) when you'd rather fail fast. ## A note on `updated_since` [Section titled “A note on updated\_since”](#a-note-on-updated_since) The analytics commands (`metrics velocity`, `funnel`) query deals by `updated_since`. The v2 API requires **RFC 3339 timestamps at seconds precision** — fractional seconds (milliseconds) are rejected. pdcli formats these for you (e.g. `2026-03-06T00:00:00Z`); if you pass a timestamp through `pdcli api`, drop the milliseconds yourself. See [Exit codes](/pdcli/automation/exit-codes/) for how the HTTP statuses above map to deterministic exit codes.
# Security model
> Where pdcli keeps your tokens, how the raw api passthrough is host-locked, and how credentials stay out of logs.
`pdcli` is designed so a credential is hard to leak, even by mistyping a command. Three properties enforce that: tokens live only in the OS keychain, the raw `api` passthrough is host-locked, and tokens are redacted from output. ## Tokens live only in the OS keychain [Section titled “Tokens live only in the OS keychain”](#tokens-live-only-in-the-os-keychain) Credentials are stored exclusively in your operating system keychain via [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring) — macOS Keychain, Windows Credential Manager, or libsecret / GNOME Keyring on Linux. They are **never** written to disk in plaintext. Only the company domain and non-secret per-profile settings go into the config file; the token does not. ### Hard-fail writes when there's no keychain [Section titled “Hard-fail writes when there's no keychain”](#hard-fail-writes-when-theres-no-keychain) If no keychain is available, writes that would store a credential **fail by design** rather than fall back to plaintext:
```text
OS keychain unavailable. pdcli stores credentials in your operating system keychain
(macOS Keychain, Windows Credential Manager, or libsecret on Linux) and refuses to write
them to disk in plaintext. Enable a system keychain and retry.
```
This is exit `78`. Reads and deletes **degrade gracefully** — `getToken` returns `null` (you'll be prompted to authenticate) and `logout` is a no-op rather than an error. The practical consequence: on a headless box with no keychain, use env-var auth (below) instead of `auth login`. See [Troubleshooting](/pdcli/reference/troubleshooting/). ### OAuth bundle, including the client secret [Section titled “OAuth bundle, including the client secret”](#oauth-bundle-including-the-client-secret) When you authenticate with `auth login --oauth`, the **entire OAuth bundle** is kept in the keychain: the access token, refresh token, expiry, `api_domain`, client ID, and the **client secret** — never in config. Access tokens refresh automatically before expiry and once on a `401`. The refreshed bundle is written straight back to the keychain. ## Env-var auth keeps tokens out of shell history [Section titled “Env-var auth keeps tokens out of shell history”](#env-var-auth-keeps-tokens-out-of-shell-history) For CI and scripts, set the token in the environment instead of passing it as a flag:
```bash
PDCLI_COMPANY_DOMAIN=acme PDCLI_API_TOKEN=xxxxxxxx pdcli deal list
```
Env auth takes precedence over the keychain, so it needs no keychain at all — ideal for headless runners. Prefer the prompt or env over `--api-token` on the command line, which would land the token in your shell history. See [CI recipes](/pdcli/automation/ci/). ## The raw `api` passthrough is host-locked [Section titled “The raw api passthrough is host-locked”](#the-raw-api-passthrough-is-host-locked) `pdcli api ` lets you hit endpoints pdcli doesn't wrap yet. It is **locked to your Pipedrive host** — the company domain (`https://{company}.pipedrive.com`) in token mode, or the OAuth `api_domain`. Before sending, pdcli resolves the path against that base origin and **compares origins**; if they don't match, it refuses:
```text
Refusing to send request outside your Pipedrive company host
(https://acme.pipedrive.com): https://evil.example.com
```
This is why you pass **paths, not full URLs** (`pdcli api GET /api/v2/deals`). A hallucinated or mistyped absolute URL can't exfiltrate your token to another host. There is no generic `api.pipedrive.com` data host — every request goes to your company subdomain. The same lock applies to file downloads and uploads. ## Redaction and identification [Section titled “Redaction and identification”](#redaction-and-identification) * **Redaction.** Tokens are sent only as headers (`x-api-token` for tokens, `Authorization: Bearer` for OAuth) and are never echoed in logs, errors, or `--verbose` output. `--verbose` shows the request path, status, and Pipedrive's `error_info`, but not credentials. * **User-Agent.** Every request identifies itself as `pdcli/` (e.g. `pdcli/0.5.0`). For where each setting is stored and the full precedence order, see [Config & environment](/pdcli/reference/config/).
# Contributing
> How to set up pdcli for development, the TDD workflow, and how changes get released.
`pdcli` is open source (MIT) and contributions are welcome. This is the short version — the canonical, always-current guide lives in [`CONTRIBUTING.md`](https://github.com/wavyx/pdcli/blob/main/CONTRIBUTING.md) in the repository. ## Setup [Section titled “Setup”](#setup) You need **Node.js 20+**. Clone, install, and run the CLI straight from source — no build, no global install:
```bash
git clone https://github.com/wavyx/pdcli.git
cd pdcli
npm install
./bin/dev.js --help
```
`bin/dev.js` loads a local `.env`, so you can point at a sandbox with `PDCLI_COMPANY_DOMAIN` / `PDCLI_API_TOKEN` without touching your keychain. ## How we work [Section titled “How we work”](#how-we-work) * **Test-driven, always.** Red → green → refactor: write the failing test first, watch it fail for the right reason, then write the minimal code to pass. No production code lands without a failing test. * **Coverage is enforced at 100%** (statements, branches, functions, lines) — exercise the error paths, not just the happy path. * **Lint before every commit:** `npm run lint` runs the same checks as CI (`eslint . && prettier --check .`); `npm run lint:fix` autofixes. * **[Conventional Commits](https://www.conventionalcommits.org/)**, scoped when useful — `feat(deal): …`, `fix(client): …`, `docs: …`, `test: …`. * **Stage explicit paths** — never `git add -A`. Scratch/handoff docs, the `design/` directory, and screenshots are git-ignored and must not be committed.
```bash
npm test # full suite
npx vitest run test/deal/list.test.js # a single file
npm run test:coverage # suite + coverage report
```
## Release flow [Section titled “Release flow”](#release-flow) Releases are tag-driven and automated (maintainers only): bump the version and update `CHANGELOG.md`, then push a `vX.Y.Z` tag. CI re-runs lint and coverage, publishes to npm via OIDC trusted publishing with provenance, packs native tarballs, and creates the GitHub Release from the changelog. ## Security [Section titled “Security”](#security) Don't put secrets — API tokens, OAuth credentials — in issues, PRs, or test fixtures. Report vulnerabilities privately to the maintainers rather than opening a public issue.
# Sales analytics
> Sales velocity, an approximate conversion funnel, and a per-stage pipeline health snapshot — computed locally from your deals.
pdcli ships a handful of read-only analytics commands. Each fetches deals (and stages/activities or goals where needed) and computes the numbers locally, so they work against any account without extra setup. All accept the global `--output`/`--jq`/`--fields` flags. ## `metrics velocity` [Section titled “metrics velocity”](#metrics-velocity) The Sales Velocity Equation: how much deal value your pipeline produces per day.
```plaintext
velocity/day = (open opportunities × win rate × avg won value) / avg cycle days
```
The four levers are measured over a trailing window (`--period`, default `90d`, accepts `Nd` or `Nm`), scoped optionally to a `--pipeline` and/or `--owner`: * **Open opportunities** — count of currently open deals. * **Win rate** — won / (won + lost) among deals *decided* in the window, keyed on `won_time`/`lost_time`. * **Avg won value** — mean value of deals won in the window. * **Avg cycle days** — mean of `won_time − add_time` for those won deals.
```bash
pdcli metrics velocity --period 30d --pipeline 1
```
```text
┌────────────────────┬───────────────────┐
│ Metric │ Value │
├────────────────────┼───────────────────┤
│ Open opportunities │ 58 │
│ Win rate (30d) │ 41.2% (7W/10L) │
│ Avg won value │ 8200 │
│ Avg cycle (days) │ 34.5 │
│ Velocity / day │ 5676 │
└────────────────────┴───────────────────┘
```
If a lever can't be computed (no decided deals, no won deals, or a zero cycle), it shows `n/a` and velocity is `n/a` rather than a misleading zero. ## `deal summary` [Section titled “deal summary”](#deal-summary) A one-call rollup of deal value, computed **server-side**: Pipedrive returns per-currency totals, a probability-weighted total, and a deal count, so there's no list to page through. It's the cheapest way to ask "what's my pipeline worth right now?"
```bash
pdcli deal summary
pdcli deal summary --status open --pipeline 1
```
```text
┌──────────┬─────────┬──────────┬───────┐
│ Currency │ Total │ Weighted │ Count │
├──────────┼─────────┼──────────┼───────┤
│ EUR │ €148000 │ €132000 │ 24 │
│ USD │ $52000 │ $41600 │ 9 │
└──────────┴─────────┴──────────┴───────┘
```
Narrow the set with `--status` (`open`/`won`/`lost`), `--pipeline`, `--stage`, or a saved `--filter`. Totals come pre-formatted per currency from the API; `--output json` returns the raw summary object (per-currency totals, grand totals, and counts) for scripting. ## `funnel` [Section titled “funnel”](#funnel) Stage-to-stage conversion. **This is an approximation, stated honestly:** Pipedrive doesn't hand back per-deal stage history in a single cheap call, so the funnel infers stage reach from each closed deal's *final* stage — a deal counts as having reached every stage up to and including the one it ended in, and won deals count for every stage. Accurate per-stage flow would need per-deal history mining; this stays one list call per status. Closed deals are taken over `--period` (default `90d`). The current open distribution per stage is shown alongside.
```bash
pdcli funnel --pipeline 1 --period 180d
```
```text
┌──────────────────────┬──────────────────────────┬─────────────────┬──────────┬────────────┐
│ Stage │ Reached (closed, 180d) │ Conv. from prev │ Open now │ Open value │
├──────────────────────┼──────────────────────────┼─────────────────┼──────────┼────────────┤
│ Qualified │ 120 │ │ 54 │ 4391545 │
│ Proposal Made │ 64 │ 53% │ 12 │ 980000 │
│ Negotiations Started │ 28 │ 44% │ 2 │ 81909 │
└──────────────────────┴──────────────────────────┴─────────────────┴──────────┴────────────┘
```
When your account has more than one pipeline, `--pipeline ` is **required** — without it pdcli lists the pipeline IDs and exits 64. With a single pipeline it's inferred. ### Exact transitions: `--exact` [Section titled “Exact transitions: --exact”](#exact-transitions---exact) When the approximation isn't good enough, `--exact` mines the **real** stage transitions from each deal's changelog instead of inferring reach from the final stage. A deal counts as *entering* a stage only when it was actually observed there: every stage the deal moved into, plus its starting stage. A deal created directly in stage 3 — with no stage transitions — counts as entering stage 3 only, not stages 1 and 2.
```bash
pdcli funnel --pipeline 1 --exact
```
The accuracy costs one request per deal. Above 100 deals, pdcli warns on stderr about the request volume before it starts mining (the rate limiter then paces the calls); the table trades the approximate `Reached` / `Open` columns for an observed `Entered` count, with the won total reported on a single summary line under the table. The per-stage ratio is labelled `Entered vs prev` rather than a conversion percentage, because exact entries are non-monotonic and the ratio can exceed 100%. In `--exact` mode, `--period` scopes only the closed (won/lost) deals it mines; **open deals are always included** regardless of the window. If a single deal's changelog can't be fetched, that deal is skipped (not the whole run) and pdcli notes how many were skipped on stderr. ## Time intelligence [Section titled “Time intelligence”](#time-intelligence) Three commands answer questions Pipedrive's UI simply doesn't: **where are deals rotting?** (`metrics aging`), **which close dates keep slipping?** (`metrics slippage`), and **what's the real transition graph between stages, backward edges and all?** (`metrics conversion-matrix`). All three reconstruct stage and close-date history by **mining each deal's changelog — one request per deal, 20 tokens each.** That's the cost of accuracy: the changelog is the only source that records when a deal actually entered a stage or had its close date moved. Above 100 deals, pdcli warns on stderr before it starts (the rate limiter then paces the calls); a deal whose changelog can't be fetched is skipped, counted, and reported on stderr rather than aborting the run. `--pipeline ` is required when the account has more than one pipeline, inferred otherwise. ### `metrics aging` [Section titled “metrics aging”](#metrics-aging) Days-in-current-stage for every open deal, bucketed, so you can see at a glance how much value is going stale and where. For each stage it also mines the **completed** dwell distribution (entry → next-entry across all deals) and reports per-stage **p50/p90**, then flags how many open deals have now sat in the stage longer than its own p90 — the deals most likely to be quietly dying.
```bash
pdcli metrics aging --pipeline 1 --buckets 30,60,90
```
```text
┌──────────────────────┬───────────┬───────────┬───────────┬───────────┬──────────────────┐
│ Stage │ 0-30 │ 30-60 │ 60-90 │ 90+ │ > p90 dwell │
├──────────────────────┼───────────┼───────────┼───────────┼───────────┼──────────────────┤
│ Qualified │ 31 (820000) │ 12 (410000) │ 6 (180000) │ 5 (240000) │ 4 (p90 47d) │
│ Proposal Made │ 7 (390000) │ 3 (120000) │ 1 (60000) │ 1 (80000) │ 2 (p90 22d) │
│ Negotiations Started │ 1 (40000) │ 1 (42000) │ 0 │ 0 │ — │
└──────────────────────┴───────────┴───────────┴───────────┴───────────┴──────────────────┘
```
Each bucket cell is `count (summed value)`; `0` when empty. Buckets are `0-N1 / N1-N2 / … / last+` with the lower bound inclusive and the upper exclusive, so a deal sitting exactly 30 days lands in `30-60`. The `> p90 dwell` column shows the count past p90 with the threshold itself (`—` when the stage has no completed-dwell history to learn a p90 from). **One limitation, stated honestly:** a deal's **starting** stage shows an `Unknown` dwell. The changelog records stage *transitions*, and a deal is created *in* its first stage — there is no entry-transition into it — so pdcli can't timestamp when it arrived. Those deals are counted in an `Unknown` column (shown only when present) rather than bucketed against a guess. ### `metrics slippage` [Section titled “metrics slippage”](#metrics-slippage) Open deals whose `expected_close_date` keeps getting pushed out. pdcli walks each deal's close-date changes, counts the forward **pushes**, and reports the **net days slipped** (original → current). `--min-pushes` (default 1) filters to the serial offenders — the deals whose timeline you can no longer trust.
```bash
pdcli metrics slippage --pipeline 1 --min-pushes 2
```
```text
┌──────┬──────────────────┬───────┬────────┬──────────────────┬──────────────────────────┐
│ Deal │ Title │ Owner │ Pushes │ Net days slipped │ Close date │
├──────┼──────────────────┼───────┼────────┼──────────────────┼──────────────────────────┤
│ 204 │ Acme renewal │ 42 │ 4 │ 96 │ 2026-02-15 → 2026-05-22 │
│ 311 │ Globex expansion │ 17 │ 3 │ 61 │ 2026-03-01 → 2026-05-01 │
└──────┴──────────────────┴───────┴────────┴──────────────────┴──────────────────────────┘
```
A high push count with a large net slip is the classic "always closing next month" deal. `Owner` is a user ID — resolve it with `user find` / `user list` (see the [audit guide](/pdcli/guides/audit/)). ### `metrics conversion-matrix` [Section titled “metrics conversion-matrix”](#metrics-conversion-matrix) The real stage-transition graph. Unlike `funnel`, which hides backward moves and collapses re-entries, this counts **every** `stage_id` hop as a directed edge and reports its occurrence count — so a deal that bounces 1→2→1→2 contributes two 1→2 edges and one 2→1 edge. Won/Lost are added as terminal columns, attributed to the stage the deal sat in when it closed. The full source×destination grid is too wide to read, so the table renders a long-format **edge list** with a forward/backward tag, plus a per-source **forward-rate** summary underneath.
```bash
pdcli metrics conversion-matrix --pipeline 1
```
```text
┌──────────────────────┬──────────────────────┬───────┬───────────┐
│ From │ To │ Edges │ Direction │
├──────────────────────┼──────────────────────┼───────┼───────────┤
│ Qualified │ Proposal Made │ 64 │ forward │
│ Qualified │ Lost │ 22 │ forward │
│ Proposal Made │ Negotiations Started │ 28 │ forward │
│ Proposal Made │ Qualified │ 9 │ backward │
│ Negotiations Started │ Won │ 18 │ forward │
│ Negotiations Started │ Proposal Made │ 4 │ backward │
└──────────────────────┴──────────────────────┴───────┴───────────┘
┌──────────────────────┬───────────┬───────────┐
│ Source stage │ Edges out │ Forward % │
├──────────────────────┼───────────┼───────────┤
│ Qualified │ 86 │ 100% │
│ Proposal Made │ 41 │ 78% │
│ Negotiations Started │ 22 │ 82% │
└──────────────────────┴───────────┴───────────┘
```
`--output json` returns the raw object — `sources`, `destinations`, the dense `matrix`, and the `edges` / `backwardEdges` lists — for feeding into a graph or a spreadsheet. The backward edges are the ones worth a second look: they're deals your reps walked *back* down the pipeline, often the precursor to a slip or a loss. The process-compliance side of this same changelog data — gate-skips and regressions with actor attribution — lives in [`audit stage-skips`](/pdcli/guides/audit/#process-compliance-audit-stage-skips). ## `metrics coverage` [Section titled “metrics coverage”](#metrics-coverage) Are you carrying enough pipeline to hit your number? `metrics coverage` weighs your **open** pipeline against the revenue still needed to reach a quota and reports the coverage ratio. The classic 3× rule is defined on raw pipeline value, so that drives the verdict; a probability-weighted coverage figure (same weighting `pipeline health` uses) is shown alongside as the risk-adjusted view. The quota comes from your active revenue goal via the [Goals API](https://developers.pipedrive.com/docs/api/v1/Goals), measured over `--period` (default `90d`). `--target ` overrides it with a manual number and skips the Goals API entirely — useful when no goal is configured. Coverage collapses open value into a single ratio, so it can't mix currencies. If the pipeline holds deals in more than one currency the command exits **64**; scope it to one with `--currency ` (e.g. `--currency USD`).
```bash
pdcli metrics coverage --pipeline 1
pdcli metrics coverage --target 500000
```
```text
┌────────────────────┬──────────┐
│ Metric │ Value │
├────────────────────┼──────────┤
│ Open pipeline │ 1480000 │
│ Weighted pipeline │ 1320000 │
│ Quota │ 500000 │
│ Progress │ 180000 │
│ Remaining │ 320000 │
│ Coverage ratio │ 4.6x │
│ Weighted coverage │ 4.1x │
│ Verdict │ healthy │
└────────────────────┴──────────┘
```
The verdict applies the classic 3× rule of thumb against `open pipeline ÷ remaining gap`: **≥ 3× is `healthy`, 2–3× is `borderline`, below 2× is `low`** (and `covered` once progress already meets the quota). When there's no active revenue goal and you didn't pass `--target`, the command exits **64** with guidance to create a goal or supply a target. ## `pipeline health` [Section titled “pipeline health”](#pipeline-health) A per-stage snapshot of your **open** deals — what's there, what it's worth, and what's neglected.
```bash
pdcli pipeline health --pipeline 1
```
```text
┌──────────────────────┬──────┬────────────┬──────────┬────────────┬──────────────┬────────────┐
│ Stage │ Open │ Value │ Weighted │ Stale >14d │ No next step │ Past close │
├──────────────────────┼──────┼────────────┼──────────┼────────────┼──────────────┼────────────┤
│ Qualified │ 54 │ 4391545 │ 878309 │ 12 │ 31 │ 4 │
│ Proposal Made │ 12 │ 980000 │ 490000 │ 3 │ 5 │ 1 │
│ Negotiations Started │ 2 │ 81909 │ 65527 │ 1 │ 2 │ 0 │
└──────────────────────┴──────┴────────────┴──────────┴────────────┴──────────────┴────────────┘
```
Column by column: | Column | Meaning | | ---------------- | ----------------------------------------------------------------------------------------------------------- | | **Open** | Open deals currently in the stage. | | **Value** | Sum of those deals' `value`. | | **Weighted** | Value × probability. Per-deal `probability` wins; otherwise the stage's `deal_probability`; otherwise 100%. | | **Stale >14d** | Open deals not updated in more than 14 days. | | **No next step** | Open deals with no future, not-done activity scheduled. | | **Past close** | Open deals whose `expected_close_date` is before today. | Like `funnel`, `--pipeline` is required when there's more than one pipeline and inferred otherwise. These same hygiene signals drive the [data-hygiene audit](/pdcli/guides/audit/) — `audit` applies them account-wide with severities and a CI gate. ## `metrics forecast` [Section titled “metrics forecast”](#metrics-forecast) Pipedrive's forecast view is UI-only. `metrics forecast` reconstructs it on the command line: your **open** pipeline bucketed by close-month into three views. * **Commit** — full value of deals whose effective win-probability clears `--commit-threshold` (default `70`): the deals you're confident will close. * **Best case** — every open deal at full value: the optimistic ceiling. * **Weighted** — each deal's value × probability (`deal probability ?? stage default ?? 100`). Values are **segregated per currency** — a USD deal and an EUR deal are different units, so they are never summed together (the same rule `metrics coverage` and `deal summary` follow). A deal with no `expected_close_date` lands in a `no-date` bucket; one with no currency under `(none)`.
```bash
pdcli metrics forecast --pipeline 1
pdcli metrics forecast --commit-threshold 80 --output json
```
```text
┌─────┬─────────┬───────┬──────────┬──────────┐
│ Cur │ Month │ Deals │ Commit │ Best case│ … Weighted
├─────┼─────────┼───────┼──────────┼──────────┤
│ EUR │ 2026-07 │ 2 │ 40000 │ 55000 │ 48200
│ USD │ 2026-07 │ 6 │ 120000 │ 180000 │ 142500
│ USD │ 2026-08 │ 4 │ 90000 │ 140000 │ 101000
│ USD │ no-date │ 1 │ 0 │ 20000 │ 12000
└─────┴─────────┴───────┴──────────┴──────────┘
Totals by currency:
…
```
`--pipeline` is required only when the account has more than one. For quota-vs-pipeline coverage, see [`metrics coverage`](#metrics-coverage); to combine forecast with everything else in one shot, see [`digest`](#digest). ## `rep scorecard` [Section titled “rep scorecard”](#rep-scorecard) Per-rep performance across **all** pipelines (narrow with `--pipeline`/`--owner`). It reuses the velocity equation per owner and adds the hygiene a manager actually chases.
```bash
pdcli rep scorecard --period 90d
pdcli rep scorecard --owner 42 --output json
```
```text
┌───────┬────────┬──────┬───────────────────┬───────────┬────────────┬───────┬────────────┬─────────┬────────────┐
│ Rep │ Active │ Open │ Win rate │ Cycle (d) │ Velocity/d │ Stale │ Past close │ No date │ No contact │
├───────┼────────┼──────┼───────────────────┼───────────┼────────────┼───────┼────────────┼─────────┼────────────┤
│ Alice │ yes │ 14 │ 58% (11W/8L) │ 34 │ 2380 │ 2 │ 1 │ 0 │ 0 │
│ Bob │ no │ 9 │ n/a │ n/a │ n/a │ 4 │ 2 │ 3 │ 1 │
└───────┴────────┴──────┴───────────────────┴───────────┴────────────┴───────┴────────────┴─────────┴────────────┘
```
Win rate and cycle are decided over the trailing `--period` (default `90d`). Owners with no matching user fall back to `#`; deals with no owner roll up under **Unassigned**. Reps are ordered by velocity per day (those with too little data to compute it sort last). ## `digest` [Section titled “digest”](#digest) The Monday packet in one command. A single pipeline-scoped fetch is fanned into velocity, pipeline health, coverage, funnel, forecast and the must-fix hygiene checks — no need to run six commands.
```bash
pdcli digest --pipeline 1 # structured table in the terminal
pdcli digest --output json # the whole packet for agents
pdcli digest --deep # + changelog-mined aging/slippage/stage-skips
pdcli digest --format md --out monday.md # shareable markdown artifact
pdcli digest --format html --out monday.html
```
By default `digest` skips changelog mining and stays cheap. `--deep` mines each deal's history (one request per deal, the usual >100-deal warning applies) to add aging, close-date slippage and stage-skip sections. The quota comes from your revenue goal over `--period`, or `--target `; if no goal is configured the coverage section is simply omitted (the digest never fails for that reason). `--format md|html` renders the packet as a shareable document — pipe it to Slack/email from cron, or write it to a file with `--out`. These artifact formats are distinct from the global `--output table|json|yaml|csv` (which stays for scripting the structured packet).
# The raw api escape hatch
> Call any Pipedrive v1 or v2 endpoint directly — host-locked to your own company domain so a mistyped URL can't leak your token.
`pdcli api` is the escape hatch for anything the typed commands don't wrap yet. It sends a raw request to a path you give and prints the JSON response, while still using your stored credentials and the same rate-limit handling as every other command.
```bash
pdcli api [--body ...]
```
`METHOD` is one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. The path can target **either API version** — v1 and v2 both work:
```bash
pdcli api GET /api/v2/deals
pdcli api GET /api/v1/currencies
pdcli api POST /api/v2/deals --body '{"title":"New deal"}'
pdcli api DELETE /api/v1/webhooks/1
```
## Host-locking [Section titled “Host-locking”](#host-locking) The request is **locked to your own Pipedrive host** — the `{company}.pipedrive.com` origin from your token-mode login, or the `api_domain` returned by OAuth. pdcli resolves the path against that origin and refuses anything that resolves elsewhere, including **other `*.pipedrive.com` subdomains**. A mistyped, hallucinated, or attacker-supplied absolute URL can't be used to exfiltrate your token. The refusal exits 78:
```bash
pdcli api GET https://evil.example.com/steal
```
```text
Error: Refusing to send request outside your Pipedrive company host (https://acme.pipedrive.com): https://evil.example.com
```
There is no generic `api.pipedrive.com` data host — always use your company origin via a relative path like `/api/v2/deals`. ## Request bodies [Section titled “Request bodies”](#request-bodies) For `POST`, `PUT`, and `PATCH`, supply a JSON body with `--body`, which takes either an inline string or an `@file` path (`GET` and `DELETE` ignore it):
```bash
pdcli api POST /api/v2/deals --body '{"title":"Inline JSON"}' # inline string
pdcli api POST /api/v2/deals --body @new-deal.json # @file
```
The body is parsed as JSON before sending; invalid JSON fails before any request. ## Filtering with `--jq` [Section titled “Filtering with --jq”](#filtering-with---jq) The response is printed as pretty JSON by default. Add `--jq` to filter it with a jq expression — handy for pulling one value out of a raw call:
```bash
pdcli api GET /api/v2/pipelines --jq '.data[] | {id, name}'
```
## v2 notes [Section titled “v2 notes”](#v2-notes) When you hit v2 endpoints directly, remember the v2 conventions: * **Use `PATCH` for updates**, not `PUT` — v2 update semantics are partial (only the fields you send change). * **Bodies are JSON only.** v2 doesn't take form-encoded data. * Lists use **cursor** pagination (`cursor`/`limit`, `limit` max 500); v1 lists use **offset** pagination (`start`/`limit`). See [How pdcli talks to Pipedrive](/pdcli/concepts/api-model/). `pdcli api` makes a single request — it does **not** auto-paginate — so pass `cursor`/`start` yourself for more pages. ## When to reach for it [Section titled “When to reach for it”](#when-to-reach-for-it) Use the typed commands when they exist — they resolve custom-field names, format tables, and paginate for you. Reach for `pdcli api` when you need an endpoint pdcli doesn't wrap yet, for example: * **Currencies** — `GET /api/v1/currencies` * **Lead labels** — `GET /api/v1/leadLabels` * **Recents / changelog** — `GET /api/v1/recents` * Any other v1/v2 endpoint, including newer ones, against your own host. Everything else carries over: errors map to the same [exit codes](/pdcli/automation/exit-codes/), 429s back off, and the token is never printed.
# Data-hygiene audit
> Eleven data-quality checks for stale deals, missing fields, duplicates, and overdue pileups — with a CI gate.
`pdcli audit` runs 11 data-hygiene checks across your deals, persons, organizations, and activities, and prints a findings count per check. Run a subset with `--checks`, see the flagged items with `--verbose`, and fail CI on serious findings with `--strict`.
```bash
pdcli audit
```
```text
┌─────┬──────────────────────────────────────────────────────┬──────────┐
│ Sev │ Check │ Findings │
├─────┼──────────────────────────────────────────────────────┼──────────┤
│ ● │ Open deals untouched for >14 days │ 18 │
│ ● │ Open deals with no future activity scheduled │ 31 │
│ ● │ Open deals past their expected close date │ 4 │
│ ● │ Open deals missing owner, person/org, value, or currency │ 7 │
│ ● │ Open deals far older than the typical won cycle │ 2 │
│ ○ │ Closed deals missing their close timestamp │ 0 │
│ ● │ Persons sharing the same email │ 3 │
│ ● │ Persons with neither email nor phone │ 12 │
│ ○ │ Organizations with the same normalized name │ 5 │
│ ○ │ Overdue open activities piling up per owner │ 44 │
│ ○ │ Deals with a value but no currency │ 0 │
└─────┴──────────────────────────────────────────────────────┴──────────┘
```
`●` is a **must**-severity check, `○` is a **should**. Only `must` checks gate `--strict`. ## The checks [Section titled “The checks”](#the-checks) | Name | Sev | Detection rule | | ----------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `stale-deals` | must | Open deal where `now − update_time > 14 days`. | | `no-next-activity` | must | Open deal with no not-done activity due today or later linked to it. | | `past-close-date` | must | Open deal whose `expected_close_date` is before today. | | `missing-fields` | must | Open deal missing any of: owner; both person and org; a value > 0; or currency when it has a value. | | `ancient-deals` | must | Open deal whose age (`now − add_time`) exceeds 2× the avg won cycle, or 168 days when no won deals exist. | | `missing-close-time` | should | A `won` deal with no `won_time`, or a `lost` deal with no `lost_time`. | | `duplicate-persons` | must | Two or more persons sharing the same email (trimmed, lowercased). | | `uncontactable-persons` | must | Person with neither a non-empty email nor phone. | | `duplicate-orgs` | should | Two or more orgs with the same normalized name (lowercased, common suffixes like Inc/Ltd/LLC/GmbH stripped, non-alphanumerics removed), **plus** fuzzy near-matches that normalize differently but score Jaro-Winkler ≥ 0.92. | | `overdue-activities` | should | Open (not-done) activities past due, counted per owner. | | `currency-missing` | should | Open deal with a value > 0 but no currency. | The thresholds are constants in the audit: stale = 14 days, ancient = 2× the avg won cycle (`won_time − add_time` across won deals) with a 168-day fallback when there are no won deals to learn from. The `Findings` count is the number of flagged items per check — except `overdue-activities`, where it's the total overdue activities summed across owners. `duplicate-orgs` reports two shapes of finding. Exact groups are tagged `kind: exact` with the shared normalized `name` and the `ids`. Fuzzy near-matches — pairs whose names normalize differently but still score [Jaro-Winkler](https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance) ≥ 0.92 — are tagged `kind: fuzzy`, carry both `names` and `ids`, and include the `score`:
```text
{"kind":"exact","name":"acme","ids":[12,48]}
{"kind":"fuzzy","names":["acmecorp","acmecrop"],"ids":[12,93],"score":0.9667}
```
## Fix what the audit finds [Section titled “Fix what the audit finds”](#fix-what-the-audit-finds) The duplicate checks pair naturally with the merge commands — close a duplicate finding by folding the loser into the survivor:
```bash
pdcli person merge 123 --into 456
pdcli org merge 12 --into 48
```
**The positional `` is the losing record and Pipedrive deletes it**; `--into` is the survivor whose data wins on conflict. Both commands confirm before deleting unless you pass `--yes`. So a `duplicate-persons` or `duplicate-orgs` finding becomes a one-liner: merge the duplicate `id` into the one you want to keep. When the right fix is to change a record's *type* — a misfiled deal that's really an early lead, or a duplicate that should fold back into the lead inbox — the convert commands move it across without re-keying. `lead convert ` turns a lead into a deal (and `deal convert ` goes the other way); both run as async jobs, so add `--wait` to block until the conversion finishes before you act on the result. On success the source record is deleted, so `deal convert` confirms first unless you pass `--yes`:
```bash
pdcli lead convert adf21080-0e10-11eb-879b-05d71fb426ec --wait # promote a lead to a deal
pdcli deal convert 204 --wait --yes # demote a misfiled deal to a lead
```
When a deal finding needs investigating before you touch it — *why* is this deal stale, or who changed its stage — `deal history ` prints its field-change audit trail (who changed what, when), newest-first. Narrow to one field with `--field`:
```bash
pdcli deal history 204 # full change log for a flagged deal
pdcli deal history 204 --field stage_id # just the stage moves
```
Findings carry owner **user IDs**, not names. Resolve them with `user list` (the whole directory) or `user find ` (by name, or `--by-email` for an address):
```bash
pdcli user list --output json
pdcli user find "jane" --output json
```
## Run a subset [Section titled “Run a subset”](#run-a-subset)
```bash
pdcli audit --checks stale-deals,duplicate-persons --verbose
```
Pass a comma-separated list of check names (the `Name` column above). An unknown name fails fast with exit 64 and lists the valid names. ## See the items: `--verbose` [Section titled “See the items: --verbose”](#see-the-items---verbose) In table output, `--verbose` prints the flagged items under each check that has findings, as one JSON object per line, **truncated at 25 items** with a "… N more" footer:
```text
Open deals untouched for >14 days
{"id":204,"title":"Acme renewal","days":31}
{"id":311,"title":"Globex expansion","days":19}
{"id":340,"title":"Initech seats","days":17}
Persons sharing the same email
{"email":"jane@acme.com","ids":[88,142]}
```
A check with more than 25 findings prints the first 25 followed by a `… N more` footer (N = total − 25). For full machine-readable output (no truncation), use `--output json` — every check includes its full `items` array. ## Gate CI: `--strict` [Section titled “Gate CI: --strict”](#gate-ci---strict) `--strict` exits **1** if any `must`-severity check has findings, naming them; otherwise exit 0. `should` findings never fail the gate.
```bash
pdcli audit --strict
```
```text
Error: 7 must-severity checks found issues: stale-deals, no-next-activity, past-close-date, missing-fields, ancient-deals, duplicate-persons, uncontactable-persons
```
## Recipe: weekly hygiene report [Section titled “Recipe: weekly hygiene report”](#recipe-weekly-hygiene-report) Run a scoped, strict audit on a schedule and let the exit code decide whether to alert. With env-var auth it's fully non-interactive:
```bash
PDCLI_COMPANY_DOMAIN=acme PDCLI_API_TOKEN=$PD_TOKEN \
pdcli audit --checks stale-deals,no-next-activity,duplicate-persons --strict --output json \
> hygiene-$(date +%F).json \
|| echo "Hygiene issues found — see report"
```
See [CI recipes](/pdcli/automation/ci/) and [Exit codes](/pdcli/automation/exit-codes/) for wiring this into a pipeline. ## Process compliance: `audit stage-skips` [Section titled “Process compliance: audit stage-skips”](#process-compliance-audit-stage-skips) Where the 11 checks above grade your *data*, `audit stage-skips` grades your *process*: did deals move through the pipeline the way they're supposed to? It mines each deal's changelog (open, won, and lost — history matters in every state) and walks the `stage_id` transitions chronologically, flagging two kinds of out-of-band move with **who made them**: * **Forward skips** — a deal that jumped a gate (destination order > source order + 1), listing the stages it leapt over. A clean single-stage advance is normal and never flagged. * **Backward moves** — a deal pulled back to an earlier stage (often sandbagging: dragging a deal down to look conservative, or to reset its age).
```bash
pdcli audit stage-skips --pipeline 1
```
```text
┌──────┬──────────┬──────────────────────────┬──────────────────────┬──────────────────────┬───────┐
│ Deal │ Kind │ From │ To │ Skipped gates │ Actor │
├──────┼──────────┼──────────────────────────┼──────────────────────┼──────────────────────┼───────┤
│ 204 │ skip │ Qualified (1) │ Negotiations Started (3) │ Proposal Made │ 42 │
│ 311 │ backward │ Negotiations Started (3) │ Qualified (1) │ │ 17 │
└──────┴──────────┴──────────────────────────┴──────────────────────┴──────────────────────┴───────┘
```
`From`/`To` carry the stage's order number in parentheses; `Skipped gates` names the stages a forward skip leapt over (empty for a backward move). `Actor` is the user ID who made the change — resolve it with `user find` / `user list`, exactly as for the deal findings above. This is **informational and always exits 0** — it surfaces moves for review rather than gating CI. Cross-pipeline hops and moves touching a since-deleted stage are ignored (stage order is only comparable within one pipeline). Like the time-intelligence metrics, it costs **one changelog request per deal (20 tokens each)** and warns on stderr above 100 deals. The conversion-side view of the same data — every transition counted, with backward edges and Won/Lost terminals — is [`metrics conversion-matrix`](/pdcli/guides/analytics/#metrics-conversion-matrix).
# Authentication
> Token mode, OAuth mode, CI patterns, and how to switch between them.
pdcli supports two auth modes. Credentials always live **only in your OS keychain** — never in plaintext on disk. If no keychain is available, commands that store credentials hard-fail by design; use [environment variables](#ci-and-headless) instead. ## Token mode (default) [Section titled “Token mode (default)”](#token-mode-default) A personal API token is the quickest way in. Generate one at [app.pipedrive.com/settings/api](https://app.pipedrive.com/settings/api) — it is tied to your user and inherits your permissions.
```bash
pdcli auth login
```
You are prompted for the company domain (`acme` from `acme.pipedrive.com`) and the token. The token is validated against the API, then stored in the keychain; the company domain is saved to your profile config. pdcli sends it as the `x-api-token` header on every request, host-locked to your domain. Non-interactively:
```bash
pdcli auth login --company acme --api-token
```
Prefer the prompt or an environment variable over `--api-token` so the token stays out of your shell history. ## OAuth mode [Section titled “OAuth mode”](#oauth-mode) Use OAuth 2.0 with your **own** Pipedrive Developer Hub app (bring-your-own client). In the Developer Hub, register the callback URL exactly as:
```text
http://127.0.0.1:9999/callback
```
Then run the browser authorization-code flow:
```bash
pdcli auth login --oauth
pdcli auth login --oauth --client-id --client-secret
```
pdcli opens your browser, captures the code on a local loopback server, and exchanges it for tokens. The **entire bundle — access token, refresh token, `api_domain`, and the client secret — is stored only in the keychain.** Config records just the mode and display domain. Access tokens refresh automatically: proactively when they're within \~5 minutes of expiry, and reactively on a 401. You never run a manual refresh. If port 9999 is in use, pick another with `--port` — but it must match the callback URL registered in your Developer Hub app. ## Check status and log out [Section titled “Check status and log out”](#check-status-and-log-out)
```bash
pdcli auth status
```
Token mode shows the host and a best-effort identity check:
```text
Auth Status
Profile: default
Keychain: OS keychain
API host: https://acme.pipedrive.com
Token: present (keychain)
Authenticated User
Name: Jane Doe
Email: jane@acme.com
```
OAuth mode shows the mode and time to expiry:
```text
Auth mode: OAuth (auto-refresh)
Token: present (expires in 47m)
```
Log out to clear stored credentials for the active profile (both modes):
```bash
pdcli auth logout
```
## CI and headless [Section titled “CI and headless”](#ci-and-headless) For pipelines and agents, skip the keychain entirely and pass credentials via the environment. They take precedence over any stored profile:
```bash
PDCLI_COMPANY_DOMAIN=acme PDCLI_API_TOKEN=$PIPEDRIVE_TOKEN pdcli deal list
```
For OAuth client credentials in CI, `PDCLI_CLIENT_ID` and `PDCLI_CLIENT_SECRET` are read by `auth login --oauth`. Store all secrets in your CI secret manager, never in the repo. ## Switching modes [Section titled “Switching modes”](#switching-modes) Auth mode is per-profile. Running `auth login` (token) or `auth login --oauth` on the same profile replaces the stored credentials and updates the recorded `auth_mode`. To keep both around, use separate [profiles](/pdcli/guides/configuration/):
```bash
pdcli auth login --profile token-acme
pdcli auth login --oauth --profile oauth-acme
```
# Full-account backup
> Export your whole Pipedrive account to a JSON tree with a resumable, checkpointed run.
`pdcli backup` exports your entire account to a directory of JSON files — one per resource — with a manifest checkpoint written after each resource so an interrupted run can pick up where it left off.
```bash
pdcli backup # → ./pipedrive-backup
pdcli backup --dir ./my-backup # choose the directory
pdcli backup --dir ./my-backup --resume # skip resources already done
```
The default directory is `pipedrive-backup` in the current working directory. ## What gets exported [Section titled “What gets exported”](#what-gets-exported) 18 resources, fetched sequentially. Each becomes a `.json` file containing the full list of items: | File | Source | Pager | | ------------------------- | ---------------------------- | --------- | | `deals.json` | `/api/v2/deals` | v2 cursor | | `persons.json` | `/api/v2/persons` | v2 cursor | | `organizations.json` | `/api/v2/organizations` | v2 cursor | | `activities.json` | `/api/v2/activities` | v2 cursor | | `products.json` | `/api/v2/products` | v2 cursor | | `pipelines.json` | `/api/v2/pipelines` | v2 cursor | | `stages.json` | `/api/v2/stages` | v2 cursor | | `dealFields.json` | `/api/v2/dealFields` | v2 cursor | | `personFields.json` | `/api/v2/personFields` | v2 cursor | | `organizationFields.json` | `/api/v2/organizationFields` | v2 cursor | | `productFields.json` | `/api/v2/productFields` | v2 cursor | | `activityFields.json` | `/api/v2/activityFields` | v2 cursor | | `leads.json` | `/api/v1/leads` | v1 offset | | `notes.json` | `/api/v1/notes` | v1 offset | | `users.json` | `/api/v1/users` | plain | | `filters.json` | `/api/v1/filters` | plain | | `webhooks.json` | `/api/v1/webhooks` | plain | | `currencies.json` | `/api/v1/currencies` | plain | The five `*Fields.json` files are your field **definitions** — including the per-account custom-field hash keys and their option labels — so a restored record's `custom_fields` hashes stay meaningful. Core CRM uses v2; leads, notes, users, filters, webhooks, and currencies come from v1 (which is where those endpoints live). ## The manifest and `--resume` [Section titled “The manifest and --resume”](#the-manifest-and---resume) After each resource is written, pdcli updates `manifest.json` in the backup directory with the completed resources and their item counts:
```json
{
"started_at": "2026-06-04T09:00:00.000Z",
"completed": ["deals", "persons", "organizations"],
"counts": { "deals": 1840, "persons": 5210, "organizations": 612 },
"updated_at": "2026-06-04T09:01:12.000Z"
}
```
If the run is interrupted, re-run with `--resume` and pdcli skips every resource already in the manifest, refetching only what's missing:
```bash
pdcli backup --dir ./my-backup --resume
```
```text
Backup complete: 4/18 resources exported to ./my-backup (14 skipped)
```
Without `--resume`, a fresh run starts a new manifest and re-exports everything. ## Token-cost awareness [Section titled “Token-cost awareness”](#token-cost-awareness) The export is **sequential by design** to keep the rate-limit budget predictable. Each list page costs 20 tokens; the client's 429 backoff handles any bursts (see [How pdcli talks to Pipedrive](/pdcli/concepts/api-model/)). A large account with hundreds of pages will take time — that's the trade for not tripping the limiter. ## Scheduling [Section titled “Scheduling”](#scheduling) Because `backup` is one non-interactive command with a deterministic exit code, it drops straight into cron or CI. Use env-var auth so nothing prompts:
```bash
# nightly at 02:00 — see CI recipes for full examples
0 2 * * * PDCLI_COMPANY_DOMAIN=acme PDCLI_API_TOKEN=$PD_TOKEN \
pdcli backup --dir /backups/pipedrive-$(date +\%F)
```
For incremental safety on a flaky link, point repeated runs at the same directory with `--resume`. See [CI recipes](/pdcli/automation/ci/) for a scheduled-job example. ## Diffing snapshots: `backup diff` [Section titled “Diffing snapshots: backup diff”](#diffing-snapshots-backup-diff) Because pdcli owns the backup format, it can diff two snapshots **entirely locally — zero API calls**. Point it at two backup directories (older first):
```bash
pdcli backup diff ./backup-2026-06-10 ./backup-2026-06-11
```
```text
┌───────────┬─────┬──────────┬────────────┬──────────┬──────────┐
│ Resource │ ID │ Change │ Field │ Old │ New │
├───────────┼─────┼──────────┼────────────┼──────────┼──────────┤
│ deals │ 42 │ modified │ value │ 100000 │ 150000 │
│ deals │ 42 │ modified │ Region │ EMEA │ APAC │
│ deals │ 99 │ added │ │ │ │
│ persons │ 7 │ removed │ │ │ │
└───────────┴─────┴──────────┴────────────┴──────────┴──────────┘
3 added · 1 removed · 5 modified (11 field changes)
```
Each record is classified `added` / `removed` / `modified`, and every changed field is one row with its old → new value. Custom-field hash keys and option ids are resolved to names and labels using each snapshot's own captured `*Fields.json` (still no network); pass `--raw` to keep the raw hashes. Resources present in only one snapshot are reported separately rather than shown as wholesale added/removed. `--output json` emits the full `{ summary, skipped, changes }` object for diffing in CI. ## Incremental export: `sync warehouse` [Section titled “Incremental export: sync warehouse”](#incremental-export-sync-warehouse) Where `backup` writes a full snapshot every run, `sync warehouse` appends only what changed since the last run — an **incremental NDJSON feed** for loading into a data warehouse:
```bash
pdcli sync warehouse --dir ./warehouse # first run seeds a full export
pdcli sync warehouse --dir ./warehouse # later runs append only deltas
pdcli sync warehouse --dir ./warehouse --full # rebuild from scratch
```
Each of the five incremental entities (deals, persons, organizations, activities, products) appends to `.ndjson` (one JSON object per line) and advances its **own** high-water mark in `manifest.json`. The watermark moves to the newest `update_time` seen **+ 1 second** (the API's `updated_since` is inclusive, so this avoids re-emitting the boundary record), and only after the append succeeds — an interrupted run replays rather than skips. `--since` overrides the start for every entity. Hard deletes are not captured Pull-based change capture sees creates and updates only. A record hard-deleted in Pipedrive simply stops appearing — the NDJSON log keeps its last-known row. Treat the output as an append log keyed by `(entity, id)` and dedupe / last-write-wins on load, and reconcile deletions periodically against a full `backup` key-set.
# Bulk operations & CSV import
> Update many deals at once by IDs, a saved filter, or piped stdin, and bulk-create persons and organizations from CSV.
pdcli has two bulk workflows: `deal bulk-update` for changing many existing deals at once, and `person import` / `org import` for creating records from a CSV. Both pace their writes to stay inside Pipedrive's rate limits and report per-item failures honestly. ## Bulk-updating deals [Section titled “Bulk-updating deals”](#bulk-updating-deals) `deal bulk-update` selects a set of deals and applies the same change to each. Pick the targets with **exactly one** selector:
```bash
pdcli deal bulk-update --ids 1,2,3 --stage 5 # explicit IDs
pdcli deal bulk-update --filter 9 --status won # a Pipedrive saved filter ID
pdcli deal list --status open --jq '.[].id' | pdcli deal bulk-update --owner 42 # stdin
```
`--ids` and `--filter` are mutually exclusive. With neither flag and no piped input you get exit 64 (`No targets — pass --ids, --filter, or pipe ids on stdin`). ### Scoping the set with list power-params [Section titled “Scoping the set with list power-params”](#scoping-the-set-with-list-power-params) The cleanest way to choose what bulk work touches is to scope it with `deal list` (or `person list`, `org list`, `product list`) and pipe the result in. The list commands share a small set of selectors: * `--filter ` — a Pipedrive **saved filter** ID. Build and discover IDs with [`filter list`](/pdcli/reference/commands/) (`pdcli filter list --type deals`), then reuse the same ID for both listing and `deal bulk-update --filter`. * `--ids ` — fetch an explicit set (max 100). * `--sort-by` / `--sort-direction` — order the results; the available sort fields vary by entity (e.g. deals sort on `id`, `update_time`, or `add_time`). * `--updated-since` / `--updated-until` — bound by last-modified time. Both take **RFC3339 with no fractional seconds** (`2026-06-01T00:00:00Z`). `product list` supports only `--updated-since` — products have no `--updated-until`. Because a saved filter ID works identically in `deal list` and `deal bulk-update`, you can preview the exact set, then apply the change to it:
```bash
pdcli deal list --filter 9 --sort-by update_time --sort-direction asc # see the set
pdcli deal list --filter 9 --jq '.[].id' | pdcli deal bulk-update --stage 5 # act on it
```
### What you can pipe on stdin [Section titled “What you can pipe on stdin”](#what-you-can-pipe-on-stdin) When stdin is not a TTY, pdcli reads it and accepts three shapes: * **Newline-separated IDs** — one integer per line. * **A JSON array of IDs** — `[1, 2, 3]`. * **A JSON array of objects** with an `id` field — e.g. the output of `deal list --output json`, so `pdcli deal list --status open | pdcli deal bulk-update --status won` works. ### What you can change [Section titled “What you can change”](#what-you-can-change) Any combination of `--stage`, `--pipeline`, `--status` (`open|won|lost`), `--owner`, repeatable `--field "Name=Value"` (resolved like everywhere — see [Custom fields](/pdcli/guides/custom-fields/)), and a raw `--body` JSON merge (typed flags win). You must pass at least one change, or it's exit 64. ### Preview, then confirm [Section titled “Preview, then confirm”](#preview-then-confirm) `--dry-run` lists the targets and the change without touching anything:
```bash
pdcli deal bulk-update --filter 9 --stage 5 --dry-run
```
```text
Would update 12 deals: 1, 2, 5, 8, 9, 13, 21, 34, 55, 89, 144, 233
Change: {"stage_id":5}
```
Without `--dry-run`, pdcli confirms before writing. Pass `-y`/`--yes` to skip the prompt in scripts. Declining aborts with exit 1. ### Pacing and partial failures [Section titled “Pacing and partial failures”](#pacing-and-partial-failures) Updates run **sequentially** with a short gap between them, so a burst of writes stays within the 2-second rate-limit window (each write costs 10 tokens); the client's 429 backoff covers anything that slips through. Per-deal failures are collected, not thrown — you get a full summary, and any failure exits 1:
```text
Updated 10/12 deals
✘ deal 55: Pipedrive API 404: Deal not found
✘ deal 233: Pipedrive API 403: insufficient permissions
Error: 2 of 12 updates failed
```
## Importing persons and organizations from CSV [Section titled “Importing persons and organizations from CSV”](#importing-persons-and-organizations-from-csv) `person import ` and `org import ` bulk-create records from a CSV. The first row is the header; **a `name` column is required** (case-insensitive), or it's exit 64. Duplicate column headers are rejected (exit 65) rather than silently keeping only the last cell. Each header maps to a field. Recognized special columns build standard values directly; every other header is resolved through the entity's field definitions — by human name, `field_code`, or hash key — including custom fields and their option labels. | Entity | Special columns | | ------ | ---------------------------------------------- | | person | `name`, `email`, `phone`, `org_id`, `owner_id` | | org | `name`, `owner_id` | For persons, `email` and `phone` become the primary email/phone. Empty cells are skipped.
```csv
name,email,Segment,owner_id
Jane Doe,jane@acme.com,Enterprise,7
John Roe,john@acme.com,SMB,7
```
```bash
pdcli person import people.csv --dry-run # validate every row, create nothing
pdcli person import people.csv # confirms, then creates (custom fields by name)
```
`--dry-run` runs the full validation — header mapping, field resolution, option-label lookups — against every row and reports the count without writing:
```text
24 rows valid — nothing created
```
A bad value fails the whole run before any writes, naming the offending row (exit 65):
```text
Error: CSV row 5: Unknown option "Huge" for field "Deal Size". Valid: Small, Medium, Large
```
A real import confirms (skip with `-y`/`--yes`), paces the creates like bulk-update, and reports partial failures the same way — any failure exits 1:
```text
Imported 23/24 persons
✘ John Roe: Pipedrive API 422: email already in use
Error: 1 of 24 rows failed
```
## Idempotent upsert (match-or-create) [Section titled “Idempotent upsert (match-or-create)”](#idempotent-upsert-match-or-create) `person upsert`, `org upsert`, and `deal upsert` make a write **safe to re-run**: match an existing record, then **create** it if absent or **PATCH only the fields that changed** if exactly one matches. Re-running the same command a second time is a no-op.
```bash
pdcli person upsert a@x.com --by email --field "Tier=Gold"
pdcli deal upsert "Acme expansion" --by title --body '{"value":5000}'
pdcli org upsert "D-42" --by "External ID" --field "Status=Active"
```
The match value is the positional argument; `--by` names the field to match on — a built-in key or a **searchable** custom field (`address`, `varchar`, `text`, `double`, `monetary`, `phone`): | Entity | Built-in `--by` keys | | ------ | ------------------------ | | person | `email`, `name`, `phone` | | org | `name` | | deal | `title` | ### Why it refuses instead of guessing [Section titled “Why it refuses instead of guessing”](#why-it-refuses-instead-of-guessing) Pipedrive's `exact_match` search is **not** a unique-key lookup — it's case-insensitive, and a custom-field search scans every custom field at once. So pdcli re-verifies each candidate client-side and counts the survivors: * **0 matches** → create (the match value is injected into the new record). * **1 match** → PATCH only the differing fields; if nothing differs, it reports `unchanged` and issues no write. * **more than 1** → **refuse with exit 65**, listing the colliding IDs. It never picks one.
```text
Error: --by email="a@x.com" matches 2 person records (ids: 7, 12) — refusing to guess. Narrow the match value.
```
`--dry-run` previews the action without writing. Table output prints a one-line summary (`update person #7 (2 fields)`); `--output json` emits the full action result (`action`, `id`, `changed`). ### Upserting a whole CSV [Section titled “Upserting a whole CSV”](#upserting-a-whole-csv) `person import` and `org import` gain `--upsert --match-on `: each row is matched on its value in the `--match-on` column, then created or PATCHed like the single-record upsert.
```bash
pdcli person import contacts.csv --upsert --match-on email --dry-run # preview the counts
pdcli person import contacts.csv --upsert --match-on email # apply
```
`--match-on` is required with `--upsert` and must name a column present in the CSV (else exit 64). Rows are paced like the other bulk flows; per-row failures — an ambiguous match or an empty match cell — are collected without aborting the batch and summarized by action:
```text
18 created, 5 updated, 1 unchanged
✘ email="dup@x.com": --by email="dup@x.com" matches 2 person records (ids: 7, 12) — refusing to guess. Narrow the match value.
Error: 1 of 24 rows failed
```
When every failed row is a data-validation error (ambiguous match, empty match value) the command exits **65**; a mix that includes a transport/API error exits **1**. Caution Matching relies on Pipedrive's **search index, which is eventually consistent** — a just-created record is not searchable for a short window. So upserting the *same* match value twice in quick succession (the same key on two CSV rows, or an upsert moments after a create) can still create a duplicate, because the second lookup runs before the first write is indexed. For repeatable sync, key on a stable external ID and let the index settle between runs; a periodic `backup diff` or a search will surface any duplicates that slip through.
# Profiles & configuration
> Manage multiple accounts with profiles, set per-profile config, and understand precedence.
pdcli stores settings per **profile**. Each profile has its own company domain, auth mode, and stored credentials, so you can keep several Pipedrive accounts side by side. The active profile defaults to `default`. ## Profiles [Section titled “Profiles”](#profiles) Create a profile by logging in with `--profile`; the name is created on first use:
```bash
pdcli auth login --profile work
pdcli auth login --profile personal
```
List, switch, and inspect the active profile:
```bash
pdcli profile list
```
```text
* default (authenticated)
work (authenticated)
personal (authenticated)
```
The `*` marks the active profile.
```bash
pdcli profile use work # switch the active profile
pdcli profile current # print the active profile name
```
Run a single command against another profile without switching — the global `--profile` flag works on every command:
```bash
pdcli deal list --profile work
```
## Per-profile config [Section titled “Per-profile config”](#per-profile-config) `config get/set/list` operate on the active profile (or the one named with `--profile`):
```bash
pdcli config set company_domain acme
pdcli config set default_output json
pdcli config get company_domain
pdcli config list
pdcli config unset default_output # remove a key from the active profile
```
```text
company_domain=acme
auth_mode=token
default_output=json
```
`config set default_output` validates the value — it must be `table`, `json`, `yaml`, or `csv`, otherwise the command exits 64 and lists the accepted formats. Common keys: | Key | Set by | Purpose | | ---------------- | ------------ | -------------------------------------------------------- | | `company_domain` | `auth login` | The `acme` in `acme.pipedrive.com`; builds the API host. | | `auth_mode` | `auth login` | `token` or `oauth`. | | `default_output` | you | Default output format when no `--output` flag is given. | Credentials (the token, or the OAuth bundle) are **never** in config — they live only in the OS keychain. ## Aliases [Section titled “Aliases”](#aliases) Save a frequently typed command as a short alias, then invoke it by name:
```bash
pdcli alias set wd "deal list --status won"
pdcli alias list
pdcli alias unset wd
```
Aliases are stored **globally**, not per profile — they're shared across every profile. A name that would shadow a real pdcli command is refused (exit 64), so an alias can never mask a built-in. Aliases execute with your full credentials and can wrap destructive commands (`api` DELETE, `merge`) — review aliases in shared configs before running them. Alias writes are not safe under concurrent pdcli invocations sharing one config (last write wins). ## Precedence [Section titled “Precedence”](#precedence) For any setting, the most specific source wins:
```text
command-line flag > environment variable > profile config
```
So `PDCLI_COMPANY_DOMAIN=acme pdcli deal list` overrides the profile's `company_domain`, and `PDCLI_API_TOKEN` overrides the keychain token for that run. (The `--company` and `--api-token` flags exist only on `pdcli auth login`, where they take precedence over the environment.) ## Environment variables [Section titled “Environment variables”](#environment-variables) | Variable | Effect | | ---------------------- | --------------------------------------------------------------------- | | `PDCLI_COMPANY_DOMAIN` | Company subdomain (`acme`). Overrides profile config. | | `PDCLI_API_TOKEN` | Personal API token. Used instead of the keychain; no keychain needed. | | `PDCLI_PROFILE` | Active profile name (same as `--profile`). | | `PDCLI_CLIENT_ID` | OAuth app client ID, read by `auth login --oauth`. | | `PDCLI_CLIENT_SECRET` | OAuth app client secret, read by `auth login --oauth`. | | `NO_COLOR` | Any value disables colored output (same as `--no-color`). | Together, `PDCLI_COMPANY_DOMAIN` + `PDCLI_API_TOKEN` are all you need for a fully non-interactive, keychain-free run — ideal for CI and agents:
```bash
PDCLI_COMPANY_DOMAIN=acme PDCLI_API_TOKEN=$TOKEN pdcli deal list --output json
```
## See also [Section titled “See also”](#see-also) * [Authentication](/pdcli/guides/authentication/) — token vs OAuth, switching modes. * [Output & filtering](/pdcli/automation/output/) — `default_output` and `--output`.
# Custom fields
> Discover, read, and write Pipedrive custom fields by human name — pdcli resolves the per-account hash keys and option labels for you.
In Pipedrive, every custom field has a **40-character hexadecimal hash key** instead of a readable name, and those keys are **unique to your account**. A field you call "Deal Size" might be `dcf558aac1ae4e8c4f849ba5e668430d8df9be12` in one company and a completely different hash in another. Dropdown options are stored as numeric IDs, not their labels. pdcli hides all of that. You discover fields, read them, and write them by their human names — the CLI resolves names to keys and labels to option IDs on every call. ## Discover fields [Section titled “Discover fields”](#discover-fields) `field list ` shows every field for an entity, custom fields included, with their hash keys, types, and option labels. Entities: `deal`, `person`, `org`, `product`, `activity`, `lead`, and `note`. (`field get` reads the same set; a lead inherits its deal fields, and `note` exposes the note schema.)
```bash
pdcli field list deal
```
```text
┌──────────────────────────────────────────┬───────────┬──────────┬──────────────────────┐
│ Key │ Name │ Type │ Options │
├──────────────────────────────────────────┼───────────┼──────────┼──────────────────────┤
│ title │ Title │ varchar │ │
│ value │ Value │ monetary │ │
│ dcf558aac1ae4e8c4f849ba5e668430d8df9be12 │ Deal Size │ enum │ Small, Medium, Large │
│ a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 │ Score │ double │ │
└──────────────────────────────────────────┴───────────┴──────────┴──────────────────────┘
```
`field get ` shows one field by either its human name or its hash key. For enum/set fields it lists each option's ID next to its label:
```bash
pdcli field get deal "Deal Size"
pdcli field get deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12
```
```text
┌──────────────────────────────────────────┬───────────┬──────┬──────────────────────────┐
│ Key │ Name │ Type │ Options │
├──────────────────────────────────────────┼───────────┼──────┼──────────────────────────┤
│ dcf558aac1ae4e8c4f849ba5e668430d8df9be12 │ Deal Size │ enum │ 12=Small,13=Medium,14=Large │
└──────────────────────────────────────────┴───────────┴──────┴──────────────────────────┘
```
## Managing fields [Section titled “Managing fields”](#managing-fields) Beyond discovery, pdcli manages the field schema itself — create a field, rename it, add or drop dropdown options, or delete it. These run on the v2 fields endpoints and cover the writable core entities: `deal`, `person`, `org`, and `product`. (`activity`, `lead`, and `note` fields are read-only — they show up in `field list`/`field get` but can't be created or altered here.) Every schema change **invalidates pdcli's in-process field cache automatically**, so the name⇄key and label⇄ID resolution on subsequent writes in the same run picks up the new shape without a manual refresh. ### Create a field [Section titled “Create a field”](#create-a-field) `--name` and `--type` are required. For an `enum` or `set` (multi-select) field, pass the dropdown labels with `--options` — it's required for those types and ignored for the rest:
```bash
pdcli field create deal --name "Budget" --type double
pdcli field create person --name "Tier" --type enum --options "Gold,Silver,Bronze"
```
The created field is returned with its freshly minted hash `Key`, so you can pipe it straight into a write:
```text
┌──────────────────────────────────────────┬────────┬──────┬─────────────────────────┐
│ Key │ Name │ Type │ Options │
├──────────────────────────────────────────┼────────┼──────┼─────────────────────────┤
│ a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 │ Tier │ enum │ 1=Gold,2=Silver,3=Bronze │
└──────────────────────────────────────────┴────────┴──────┴─────────────────────────┘
```
### Rename a field [Section titled “Rename a field”](#rename-a-field) `field update ` renames a field via `--name`. The field is addressed by its hash key (the `field_code` from `field list`); the `field_code` and `field_type` themselves can't be changed:
```bash
pdcli field update deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12 --name "Deal Value"
```
### Manage dropdown options [Section titled “Manage dropdown options”](#manage-dropdown-options) For `enum`/`set` fields, add and remove options without touching the rest of the schema:
```bash
pdcli field option add deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12 --label "Critical"
pdcli field option remove deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12 --option 4
```
`add` takes the new label; `remove` takes the numeric **option ID** (run `field get` to see each option's ID next to its label). Removing an option is destructive — records that held that value lose it — so it confirms first; `-y`/`--yes` skips the prompt for scripts. ### Delete a field [Section titled “Delete a field”](#delete-a-field)
```bash
pdcli field delete deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12
pdcli field delete deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12 --yes
```
Caution Deleting a field **discards the data every record stored in it** — there's no undo. The command confirms first and the prompt **defaults to No**; pass `-y`/`--yes` only when you're sure (e.g. in a script). ## Reading: names in tables, raw keys in JSON [Section titled “Reading: names in tables, raw keys in JSON”](#reading-names-in-tables-raw-keys-in-json) In table output, custom-field values are shown under their human names, and option IDs are swapped back to their labels. JSON output stays **raw** — hash keys and numeric option IDs intact — so scripts have something stable to parse.
```bash
pdcli deal get 42 # table: shows "Deal Size: Large"
pdcli deal get 42 --output json # JSON: custom_fields.{hash}: 14
```
v2 nests custom-field values under a `custom_fields` object on the record. ### Resolve in JSON too: `--resolve-fields` [Section titled “Resolve in JSON too: --resolve-fields”](#resolve-in-json-too---resolve-fields) When you want the readable names in machine output as well, opt in with `--resolve-fields`. It resolves hash keys to names and option IDs to labels in `json`, `yaml`, and `csv` output — matching what the table always shows:
```bash
pdcli deal get 1 --output json --resolve-fields
```
```text
{
"id": 1,
"title": "Acme renewal",
"custom_fields": { "Deal Size": "Large", "Score": 4.5 }
}
```
This is **opt-in**: it applies to the `get` commands and the core `list` commands (`deal`, `person`, `org`, `activity`, `product` — one field-definitions fetch covers the whole list). On lists, resolution affects json/yaml/csv only (list tables don't render custom-field columns); on `get`, the table view always resolves. If two custom fields share the same name, the second is disambiguated with a short key fragment (e.g. `Region (a1b2c3d4)`) so neither value is clobbered. ## Writing: `--field "Name=Value"` [Section titled “Writing: --field "Name=Value"”](#writing---field-namevalue) On every write command that accepts custom fields (`deal`, `person`, `org`, `product`, `activity` — `create`, `update`, and `deal bulk-update`), pass `--field "Name=Value"`. The flag is repeatable, one field per flag:
```bash
pdcli deal create --title "Acme renewal" \
--field "Deal Size=Large" \
--field "Score=4.5"
```
What resolution does for you: * **Name to key** — `Deal Size` becomes its 40-char hash. You can also pass the hash key or `field_code` directly if you prefer. * **Label to option ID** — for `enum` fields, `Large` becomes the numeric option ID. * **Set (multi-option) fields** — give comma-separated labels, each resolved to its ID: `--field "Tags=VIP,Renewal"`. * **Numeric coercion** — `double`, `monetary`, and `int` field values are converted to numbers, so `Score=4.5` is sent as `4.5`, not the string `"4.5"`. * **Custom fields nest** under `custom_fields`; standard fields go at the top level. ## When a label is wrong [Section titled “When a label is wrong”](#when-a-label-is-wrong) If you give an option label that doesn't exist, pdcli stops before writing and lists the valid options (exit code 65):
```bash
pdcli deal create --title "Sized" --field "Deal Size=Huge"
```
```text
Error: Unknown option "Huge" for field "Deal Size". Valid: Small, Medium, Large
```
An unknown field name also fails fast, pointing you at discovery (exit code 65):
```text
Error: Unknown field "Dela Size". Run: pdcli field list
```
## Same resolution everywhere [Section titled “Same resolution everywhere”](#same-resolution-everywhere) The exact same name-and-label resolution runs on single writes (`create`/`update`), on [`deal bulk-update`](/pdcli/guides/bulk/), and on [CSV imports](/pdcli/guides/bulk/) — where each CSV header is treated as a field name. So a `Deal Size` column in your spreadsheet resolves the same way `--field "Deal Size=Large"` does on the command line. ## Raw escape hatch [Section titled “Raw escape hatch”](#raw-escape-hatch) If you'd rather supply the raw v2 shape yourself, `--body` takes JSON and typed flags win over it. You'd use the hash keys directly:
```bash
pdcli deal create --title "Raw" \
--body '{"custom_fields":{"dcf558aac1ae4e8c4f849ba5e668430d8df9be12":14}}'
```
# Deal products (line items)
> Attach products to a deal as line items — add, list, update, and remove — with server-computed line sums.
A deal's **line items** are the products attached to it, each with its own price, quantity, and discount. pdcli manages them under `deal product`, a v2 command group that maps onto `/api/v2/deals/{id}/products`. The deal's product catalog entry lives in `product`; the line item that ties a product to a deal is the *attachment* you operate on here. ## List the line items [Section titled “List the line items”](#list-the-line-items)
```bash
pdcli deal product list 42
```
```text
┌────┬─────────┬────────────┬────────────┬─────┬──────────┬──────┐
│ ID │ Product │ Name │ Item price │ Qty │ Discount │ Sum │
├────┼─────────┼────────────┼────────────┼─────┼──────────┼──────┤
│ 3 │ 10 │ Consulting │ 150 │ 4 │ 0 │ 600 │
│ 7 │ 12 │ Onboarding │ 90 │ 1 │ 10 │ 80 │
└────┴─────────┴────────────┴────────────┴─────┴──────────┴──────┘
```
The `ID` column is the **attachment ID** — the handle you pass to `--attachment` when updating or removing a line, distinct from the catalog `Product` ID. Sort with `--sort-by` (`id`, `add_time`, `update_time`, or `order_nr`) and `--sort-direction` (`asc`/`desc`):
```bash
pdcli deal product list 42 --sort-by add_time --sort-direction desc
```
## Add a line item [Section titled “Add a line item”](#add-a-line-item) `--product` (catalog ID) and `--price` (per-unit item price) are required; `--quantity` defaults to `1`. Discounts, tax, and a comment are optional:
```bash
pdcli deal product add 42 --product 10 --price 90
pdcli deal product add 42 --product 10 --price 90 --quantity 3
pdcli deal product add 42 --product 10 --price 90 --discount 10 --discount-type percentage
```
`--discount-type` is `percentage` or `amount`. You don't compute the line total yourself — Pipedrive derives **`sum`** server-side from `item_price × quantity` less the discount, and returns it on the created record. That's why the `Sum` column above (e.g. `150 × 4 = 600`) isn't something pdcli adds up locally. ## Update a line item [Section titled “Update a line item”](#update-a-line-item) `update` is a v2 `PATCH` — only the fields you pass change; everything else is left alone. Identify the line with `--attachment` (the `ID` from `deal product list`):
```bash
pdcli deal product update 42 --attachment 3 --quantity 5
pdcli deal product update 42 --attachment 3 --price 120
pdcli deal product update 42 --attachment 3 --discount 15 --discount-type amount
```
Pass at least one field flag, or it's exit 64 (`Nothing to update`). Any of `--product`, `--price`, `--quantity`, `--discount`, `--discount-type`, `--tax`, and `--comments` may be combined; the server recomputes `sum` from the new values. ## Remove a line item [Section titled “Remove a line item”](#remove-a-line-item)
```bash
pdcli deal product remove 42 --attachment 3
pdcli deal product remove 42 --attachment 3 --yes
```
Like every destructive command, it confirms first; `-y`/`--yes` skips the prompt for scripts. ## Worked example [Section titled “Worked example”](#worked-example) Find a product in the catalog, attach it to a deal, and confirm the line:
```bash
pdcli product list --jq '.[] | {id, name}' # find the catalog product ID
pdcli deal product add 42 --product 10 --price 150 --quantity 4
pdcli deal product list 42 # the new line, with its server-computed Sum
```
# Relations: participants, followers, org hierarchy
> Manage the people and links around a record — deal participants (the buying committee), followers on deals/persons/orgs, and parent/related links between organizations.
Records in Pipedrive don't stand alone. A deal has a **buying committee** of extra people, a deal/person/org can have **followers** who get notified of changes, and organizations link into a **hierarchy** of parents and related companies. pdcli manages each of these as a small command group that maps onto the matching Pipedrive endpoint. ## Deal participants (the buying committee) [Section titled “Deal participants (the buying committee)”](#deal-participants-the-buying-committee) A deal's *primary* contact is its `person`; **participants** are the other people involved — the champion, the economic buyer, the blockers. Manage them under `deal participant`.
```bash
pdcli deal participant list 42
```
```text
┌────┬────────┬───────────┬──────────────────────┐
│ ID │ Person │ Name │ Added │
├────┼────────┼───────────┼──────────────────────┤
│ 3 │ 10 │ Jane Doe │ 2026-05-01 09:12:00 │
│ 7 │ 21 │ John Roe │ 2026-05-03 14:40:00 │
└────┴────────┴───────────┴──────────────────────┘
```
Add a person by their person ID, and remove a participant by the **deal-participant `ID`** from the list (the left column, distinct from the person ID):
```bash
pdcli deal participant add 42 --person 10
pdcli deal participant remove 42 --participant 3
pdcli deal participant remove 42 --participant 3 --yes
```
`remove` confirms first like every destructive command; `-y`/`--yes` skips the prompt. ## Followers [Section titled “Followers”](#followers) A **follower** is a *user* (a teammate, by user ID) who gets notified about a record's activity. Deals, persons, and organizations each have an identical `follower` group:
```bash
pdcli deal follower list 42
pdcli person follower list 17
pdcli org follower list 7
```
```text
┌──────┬──────────────────────┐
│ User │ Added │
├──────┼──────────────────────┤
│ 5 │ 2026-05-01 09:12:00 │
└──────┴──────────────────────┘
```
Add and remove a follower by **user ID** (resolve names with `user find`/`user list`). The same shape works for `deal`, `person`, and `org`:
```bash
pdcli deal follower add 42 --user 5
pdcli deal follower remove 42 --user 5
pdcli deal follower remove 42 --user 5 --yes
pdcli person follower add 17 --user 5
pdcli org follower add 7 --user 5
```
Unlike participants, followers are keyed directly by the user ID on both add and remove — there's no separate follower handle to track. ## Organization hierarchy [Section titled “Organization hierarchy”](#organization-hierarchy) Organizations link to each other two ways: a **parent** relationship (a corporate parent/subsidiary tree) and a looser **related** link between peers. Manage both under `org relationship`. List every relationship an org takes part in — on either side — with `--org`:
```bash
pdcli org relationship list --org 1481
```
```text
┌────┬────────┬───────────┬───────────┬─────────┐
│ ID │ Type │ Owner Org │ Linked Org │ Related │
├────┼────────┼───────────┼───────────┼─────────┤
│ 7 │ parent │ 1481 │ 1480 │ Globex │
└────┴────────┴───────────┴───────────┴─────────┘
```
Create a link with `--type parent|related`, naming the two orgs with `--owner` and `--linked`. **For a `parent` relationship the `--owner` is the parent and `--linked` is the daughter**; for `related` the two are interchangeable peers:
```bash
pdcli org relationship add --type parent --owner 1481 --linked 1480
pdcli org relationship add --type related --owner 1 --linked 2
```
Remove a link by its **relationship `ID`** (the left column from `list`, not an org ID):
```bash
pdcli org relationship remove 7
pdcli org relationship remove 7 --yes
```
It confirms first; `-y`/`--yes` skips the prompt for scripts.
# Command reference
> Every pdcli command, flag, and example — generated from the CLI manifest.
All 145 commands in `pdcli` v0.18.0. Every command also accepts the [global flags](/pdcli/reference/config/) `--output`, `--jq`, `--fields`, `--profile`, `--limit`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. Run `pdcli --help` for the live version. ## Top-level commands [Section titled “Top-level commands”](#top-level-commands) ### `pdcli api` [Section titled “pdcli api”](#pdcli-api) Make a raw API request (host-locked to your Pipedrive company domain)
```text
pdcli api [flags]
```
| Flag | Description | | ----------------- | ------------------------------------------------ | | `--body` \ | Request body (JSON string, @file, or pipe stdin) |
```bash
pdcli api GET /api/v2/deals
pdcli api GET /api/v1/currencies
pdcli api POST /api/v2/deals --body '{"title":"New deal"}'
pdcli api DELETE /api/v1/webhooks/1
```
### `pdcli audit` [Section titled “pdcli audit”](#pdcli-audit) Data-quality audit: stale deals, missing fields, duplicates, overdue pileups
```text
pdcli audit [flags]
```
| Flag | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `--checks` \ | Comma-separated subset of checks (stale-deals, no-next-activity, past-close-date, missing-fields, ancient-deals, missing-close-time, duplicate-persons, uncontactable-persons, duplicate-orgs, overdue-activities, currency-missing) | | `--strict` | Exit 1 when any must-severity check has findings |
```bash
pdcli audit
pdcli audit --checks stale-deals,duplicate-persons --verbose
pdcli audit --strict # exit 1 on must-severity findings (CI)
```
### `pdcli backup` [Section titled “pdcli backup”](#pdcli-backup) Export the whole account to a JSON tree (resumable, one file per resource)
```text
pdcli backup [flags]
```
| Flag | Description | | ---------------- | -------------------------------------------------- | | `--dir` \ | Target directory for the export | | `--resume` | Skip resources already completed in a previous run |
```bash
pdcli backup
pdcli backup --dir ./my-backup
pdcli backup --dir ./my-backup --resume
```
### `pdcli changes` [Section titled “pdcli changes”](#pdcli-changes) Incremental change feed across deals/persons/orgs/activities/products. Self-advancing watermark: each run resumes where the last left off and advances it past the newest change only after a successful emit, so a failed run replays rather than skips (use --peek to read without advancing).
```text
pdcli changes [flags]
```
| Flag | Description | | ------------------ | ---------------------------------------------------------------------------------- | | `--since` \ | Start point: RFC3339 timestamp or Nd/Nm. Omit to resume from the stored watermark. | | `--peek` | Read without advancing the stored watermark |
```bash
pdcli changes --since 7d
pdcli changes
pdcli changes --peek --output json
```
### `pdcli digest` [Section titled “pdcli digest”](#pdcli-digest) Monday packet: one pipeline-scoped fetch fanned into velocity, health, coverage, funnel, forecast and hygiene. --deep adds changelog-mined aging/slippage/stage-skips; --format md|html (+ --out) writes a shareable artifact for cron → Slack/email.
```text
pdcli digest [flags]
```
| Flag | Description | | ----------------------------- | --------------------------------------------------------------------------------------------------- | | `--pipeline` \ | Pipeline ID (required when the account has several) | | `--period` \ | Trailing window for closed deals / the goal (Nd or Nm) | | `--target` \ | Manual revenue quota override (skips the Goals API) | | `--commit-threshold` \ | Min effective win-probability (%) counted toward commit | | `--deep` | Mine each deal’s changelog to add aging/slippage/stage-skips (one request per deal; warns over 100) | | `--format` \ | Render the packet as a shareable artifact | | `--out` \ | Write the --format artifact to this file instead of stdout |
```bash
pdcli digest
pdcli digest --pipeline 1 --output json
pdcli digest --deep
pdcli digest --format md --out monday.md
```
### `pdcli doctor` [Section titled “pdcli doctor”](#pdcli-doctor) Run diagnostic checks on the CLI environment
```text
pdcli doctor [flags]
```
```bash
pdcli doctor
```
### `pdcli funnel` [Section titled “pdcli funnel”](#pdcli-funnel) Stage-to-stage conversion approximated from closed deals (final stage reached)
```text
pdcli funnel [flags]
```
| Flag | Description | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--period` \ | Trailing window for closed deals (Nd or Nm) | | `--pipeline` \ | Pipeline ID (required when the account has several) | | `--exact` | Mine real stage transitions from each deal’s changelog instead of approximating from the final stage (one request per deal). --period scopes only closed (won/lost) deals; open deals are always included. |
```bash
pdcli funnel
pdcli funnel --pipeline 1 --period 180d
```
### `pdcli search` [Section titled “pdcli search”](#pdcli-search) Search across deals, persons, organizations, products, leads, files, and projects
```text
pdcli search [flags]
```
| Flag | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------ | | `--item-types` \ | Comma-separated item types (deal,person,organization,product,lead,file,mail\_attachment,project) | | `--exact` | Exact match (allows 1-character terms) | | `--status` \ | Filter by deal status (only with --item-types deal) | | `--person` \ | Filter by person ID (only with --item-types deal) | | `--org` \ | Filter by organization ID (only with --item-types deal) |
```bash
pdcli search "acme"
pdcli search "acme" --item-types deal,person --output json
pdcli search "acme" --item-types deal --status open
```
### `pdcli version` [Section titled “pdcli version”](#pdcli-version) Show CLI version and environment info
```text
pdcli version [flags]
```
```bash
pdcli version
```
### `pdcli watch` [Section titled “pdcli watch”](#pdcli-watch) Anomaly poller: run the hygiene checks and emit only findings that are NEW since the last run, advancing a per-profile state. Exits 8 when new findings arm the gate (default: must-severity) so cron can branch — `pdcli watch \|\| notify`. --peek reads without advancing state.
```text
pdcli watch [flags]
```
| Flag | Description | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `--checks` \ | Comma-separated subset of checks (stale-deals, no-next-activity, past-close-date, missing-fields, ancient-deals, missing-close-time, duplicate-persons, uncontactable-persons, duplicate-orgs, overdue-activities, currency-missing) | | `--severity` \ | Which severities arm the exit-8 gate | | `--peek` | Emit and gate without advancing the stored state |
```bash
pdcli watch
pdcli watch --checks stale-deals,past-close-date
pdcli watch --severity all --output json
pdcli watch --peek
```
## pdcli activity [Section titled “pdcli activity”](#pdcli-activity) ### `pdcli activity create` [Section titled “pdcli activity create”](#pdcli-activity-create) Create an activity
```text
pdcli activity create [flags]
```
| Flag | Description | | --------------------- | -------------------------------------------------- | | `--subject` \ | Activity subject | | `--type` \ | Activity type | | `--due-date` \ | Due date (YYYY-MM-DD) | | `--due-time` \ | Due time (HH:MM) | | `--duration` \ | Duration (HH:MM) | | `--deal` \ | Linked deal ID | | `--person` \ | Linked person ID | | `--org` \ | Linked organization ID | | `--owner` \ | Owner (user) ID | | `--note` \ | Activity note | | `--done` | Mark the activity as done | | `--field` \ | Custom/standard field as "Name=Value" (repeatable) | | `--body` \ | Raw JSON body to merge (flags win) |
```bash
pdcli activity create --subject "Demo call" --type call --due-date 2026-06-10
pdcli activity create --subject "Follow up" --field "Outcome=Positive"
pdcli activity create --subject "Raw" --body '{"priority":5}'
```
### `pdcli activity delete` [Section titled “pdcli activity delete”](#pdcli-activity-delete) Delete an activity
```text
pdcli activity delete [flags]
```
| Flag | Description | | ----------- | ---------------------------- | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli activity delete 9
pdcli activity delete 9 --yes
```
### `pdcli activity get` [Section titled “pdcli activity get”](#pdcli-activity-get) Get an activity by ID
```text
pdcli activity get [flags]
```
```bash
pdcli activity get 9
pdcli activity get 9 --output json
```
### `pdcli activity list` [Section titled “pdcli activity list”](#pdcli-activity-list) List activities
```text
pdcli activity list [flags]
```
| Flag | Description | | ----------------------------------------------------- | --------------------------------------------------------------------- | | `--owner` \ | Filter by owner (user) ID | | `--deal` \ | Filter by deal ID | | `--person` \ | Filter by person ID | | `--org` \ | Filter by organization ID | | `--type` \ | Filter by activity type key (applied client-side) | | `--done` | Only completed activities | | `--todo` | Only open (not done) activities | | `--filter` \ | Filter by saved filter ID | | `--ids` \ | Comma-separated IDs to fetch (max 100) | | `--sort-by` \ | Sort field | | `--sort-direction` \ | Sort direction | | `--updated-since` \ | Only items updated at/after this RFC3339 time (no fractional seconds) | | `--updated-until` \ | Only items updated before this RFC3339 time (no fractional seconds) |
```bash
pdcli activity list
pdcli activity list --todo --deal 42
pdcli activity list --type call --output json
```
### `pdcli activity type list` [Section titled “pdcli activity type list”](#pdcli-activity-type-list) List activity types. The Key (key\_string) is what `activity --type` takes.
```text
pdcli activity type list [flags]
```
```bash
pdcli activity type list
pdcli activity type list --output json
```
### `pdcli activity update` [Section titled “pdcli activity update”](#pdcli-activity-update) Update an activity (v2 PATCH — only provided fields change)
```text
pdcli activity update [flags]
```
| Flag | Description | | --------------------- | -------------------------------------------------- | | `--subject` \ | Activity subject | | `--type` \ | Activity type | | `--due-date` \ | Due date (YYYY-MM-DD) | | `--due-time` \ | Due time (HH:MM) | | `--duration` \ | Duration (HH:MM) | | `--deal` \ | Linked deal ID | | `--person` \ | Linked person ID | | `--org` \ | Linked organization ID | | `--owner` \ | Owner (user) ID | | `--note` \ | Activity note | | `--done` | Mark the activity as done | | `--undone` | Mark the activity as not done | | `--field` \ | Custom/standard field as "Name=Value" (repeatable) | | `--body` \ | Raw JSON body to merge (flags win) |
```bash
pdcli activity update 9 --subject "Renamed"
pdcli activity update 9 --done
pdcli activity update 9 --field "Outcome=Positive"
```
## pdcli alias [Section titled “pdcli alias”](#pdcli-alias) ### `pdcli alias list` [Section titled “pdcli alias list”](#pdcli-alias-list) List all configured aliases
```text
pdcli alias list [flags]
```
```bash
pdcli alias list
```
### `pdcli alias set` [Section titled “pdcli alias set”](#pdcli-alias-set) Create or update an alias
```text
pdcli alias set [flags]
```
```bash
pdcli alias set wd "deal list --status won"
pdcli alias set open "deal list --status open --limit 50"
```
### `pdcli alias unset` [Section titled “pdcli alias unset”](#pdcli-alias-unset) Remove an alias
```text
pdcli alias unset [flags]
```
```bash
pdcli alias unset wd
```
## pdcli audit [Section titled “pdcli audit”](#pdcli-audit-1) ### `pdcli audit stage-skips` [Section titled “pdcli audit stage-skips”](#pdcli-audit-stage-skips) Stage-skip & sandbagging audit: deals that jumped gates or were pulled backward, mined from each deal’s changelog (one request per deal)
```text
pdcli audit stage-skips [flags]
```
| Flag | Description | | --------------------- | --------------------------------------------------- | | `--pipeline` \ | Pipeline ID (required when the account has several) |
```bash
pdcli audit stage-skips
pdcli audit stage-skips --pipeline 1 --output json
```
## pdcli auth [Section titled “pdcli auth”](#pdcli-auth) ### `pdcli auth login` [Section titled “pdcli auth login”](#pdcli-auth-login) Authenticate with Pipedrive (personal API token, or OAuth with --oauth)
```text
pdcli auth login [flags]
```
| Flag | Description | | -------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `--company` \ | Company domain ("acme" from acme.pipedrive.com — full URL accepted) | | `--api-token` \ | Personal API token (app.pipedrive.com/settings/api). Prefer the prompt or env so the token stays out of shell history | | `--oauth` | Use OAuth 2.0 via your own Developer Hub app (browser flow) | | `--client-id` \ | OAuth app client ID (--oauth; env PDCLI\_CLIENT\_ID) | | `--client-secret` \ | OAuth app client secret (--oauth; env PDCLI\_CLIENT\_SECRET) | | `--port` \ | OAuth callback port — must match the app's registered callback URL (--oauth) |
```bash
pdcli auth login
pdcli auth login --company acme --api-token
pdcli auth login --oauth
pdcli auth login --oauth --client-id --client-secret
```
### `pdcli auth logout` [Section titled “pdcli auth logout”](#pdcli-auth-logout) Log out and remove the stored API token
```text
pdcli auth logout [flags]
```
```bash
pdcli auth logout
```
### `pdcli auth status` [Section titled “pdcli auth status”](#pdcli-auth-status) Show current authentication status
```text
pdcli auth status [flags]
```
```bash
pdcli auth status
```
## pdcli backup [Section titled “pdcli backup”](#pdcli-backup-1) ### `pdcli backup diff` [Section titled “pdcli backup diff”](#pdcli-backup-diff) Field-level diff between two backup snapshots — added/removed/modified records and per-field changes, computed locally with no API calls
```text
pdcli backup diff [flags]
```
| Flag | Description | | ------- | ------------------------------------------------------------------------ | | `--raw` | Do not resolve custom-field names/option labels (show raw hash keys/ids) |
```bash
pdcli backup diff ./backup-mon ./backup-tue
pdcli backup diff ./old ./new --output json
pdcli backup diff ./old ./new --raw
```
## pdcli config [Section titled “pdcli config”](#pdcli-config) ### `pdcli config get` [Section titled “pdcli config get”](#pdcli-config-get) Get a config value for the active profile
```text
pdcli config get [flags]
```
```bash
pdcli config get company_domain
pdcli config get default_output
```
### `pdcli config list` [Section titled “pdcli config list”](#pdcli-config-list) List all config for the active profile
```text
pdcli config list [flags]
```
```bash
pdcli config list
```
### `pdcli config set` [Section titled “pdcli config set”](#pdcli-config-set) Set a config value for the active profile
```text
pdcli config set [flags]
```
```bash
pdcli config set company_domain acme
pdcli config set default_output json
```
### `pdcli config unset` [Section titled “pdcli config unset”](#pdcli-config-unset) Remove a config key from the active profile
```text
pdcli config unset [flags]
```
```bash
pdcli config unset default_output
```
## pdcli deal [Section titled “pdcli deal”](#pdcli-deal) ### `pdcli deal bulk-update` [Section titled “pdcli deal bulk-update”](#pdcli-deal-bulk-update) Update many deals at once (by --ids, a saved --filter, or ids piped on stdin)
```text
pdcli deal bulk-update [flags]
```
| Flag | Description | | ----------------------------- | -------------------------------------------------- | | `--ids` \ | Comma-separated deal IDs | | `--filter` \ | Pipedrive saved filter ID to select deals | | `--stage` \ | Move to stage ID | | `--pipeline` \ | Move to pipeline ID | | `--status` \ | Set status | | `--owner` \ | Assign owner (user) ID | | `--field` \ | Custom/standard field as "Name=Value" (repeatable) | | `--body` \ | Raw JSON body to merge (flags win) | | `--dry-run` | List the targets without updating anything | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli deal bulk-update --ids 1,2,3 --stage 5
pdcli deal bulk-update --filter 9 --status won
pdcli deal list --status open --jq '.[].id' | pdcli deal bulk-update --owner 42
pdcli deal bulk-update --filter 9 --stage 5 --dry-run
```
### `pdcli deal context` [Section titled “pdcli deal context”](#pdcli-deal-context) One-call denormalized deal bundle — deal + person + org + activities + notes + products + participants, custom fields resolved to names and risk flags derived. Prompt-ready for agents (the joins v2 will not do).
```text
pdcli deal context [flags]
```
| Flag | Description | | --------------------------- | --------------------------- | | `--no-activities` | Skip the activities slice | | `--no-notes` | Skip the notes slice | | `--no-products` | Skip the products slice | | `--no-participants` | Skip the participants slice | | `--activity-limit` \ | Max activities to include | | `--note-limit` \ | Max notes to include |
```bash
pdcli deal context 42
pdcli deal context 42 --no-notes --no-products
pdcli deal context 42 --output json
```
### `pdcli deal convert` [Section titled “pdcli deal convert”](#pdcli-deal-convert) Convert a deal to a lead. The conversion runs as an async job; use --wait to poll until it finishes. WARNING: on success the source deal is deleted.
```text
pdcli deal convert [flags]
```
| Flag | Description | | ------------------------- | -------------------------------------------- | | `--wait` | Poll the conversion status until it finishes | | `--timeout-secs` \ | Max seconds to poll when --wait is set | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli deal convert 42
pdcli deal convert 42 --yes
pdcli deal convert 42 --wait
```
### `pdcli deal create` [Section titled “pdcli deal create”](#pdcli-deal-create) Create a deal
```text
pdcli deal create [flags]
```
| Flag | Description | | -------------------------------- | -------------------------------------------------- | | `--title` \ | Deal title | | `--value` \ | Deal value | | `--currency` \ | Deal currency (e.g. EUR) | | `--status` \ | Deal status | | `--stage` \ | Stage ID | | `--pipeline` \ | Pipeline ID | | `--person` \ | Linked person ID | | `--org` \ | Linked organization ID | | `--owner` \ | Owner (user) ID | | `--probability` \ | Success probability (0-100) | | `--expected-close-date` \ | Expected close date (YYYY-MM-DD) | | `--field` \ | Custom/standard field as "Name=Value" (repeatable) | | `--body` \ | Raw JSON body to merge (flags win) |
```bash
pdcli deal create --title "Acme renewal" --value 5000 --currency EUR
pdcli deal create --title "Sized" --field "Deal Size=Large"
pdcli deal create --title "Raw" --body '{"probability":75}'
```
### `pdcli deal delete` [Section titled “pdcli deal delete”](#pdcli-deal-delete) Delete a deal
```text
pdcli deal delete [flags]
```
| Flag | Description | | ----------- | ---------------------------- | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli deal delete 42
pdcli deal delete 42 --yes
```
### `pdcli deal follower add` [Section titled “pdcli deal follower add”](#pdcli-deal-follower-add) Add a follower (user) to a deal
```text
pdcli deal follower add [flags]
```
| Flag | Description | | ----------------- | ----------- | | `--user` \ | User ID |
```bash
pdcli deal follower add 42 --user 5
pdcli deal follower add 42 --user 5 --output json
```
### `pdcli deal follower list` [Section titled “pdcli deal follower list”](#pdcli-deal-follower-list) List followers of a deal
```text
pdcli deal follower list [flags]
```
```bash
pdcli deal follower list 42
pdcli deal follower list 42 --output json
```
### `pdcli deal follower remove` [Section titled “pdcli deal follower remove”](#pdcli-deal-follower-remove) Remove a follower from a deal
```text
pdcli deal follower remove [flags]
```
| Flag | Description | | ----------------- | ---------------------------- | | `--user` \ | User ID | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli deal follower remove 42 --user 5
pdcli deal follower remove 42 --user 5 --yes
```
### `pdcli deal get` [Section titled “pdcli deal get”](#pdcli-deal-get) Get a deal by ID
```text
pdcli deal get [flags]
```
```bash
pdcli deal get 42
pdcli deal get 42 --output json
```
### `pdcli deal history` [Section titled “pdcli deal history”](#pdcli-deal-history) Field-change history for a deal, newest-first (the API’s native order)
```text
pdcli deal history [flags]
```
| Flag | Description | | ------------------ | ---------------------------------------------------- | | `--field` \ | Show only changes to this field key (e.g. stage\_id) |
```bash
pdcli deal history 42
pdcli deal history 42 --field stage_id
pdcli deal history 42 --limit 20 --resolve-fields
```
### `pdcli deal list` [Section titled “pdcli deal list”](#pdcli-deal-list) List deals
```text
pdcli deal list [flags]
```
| Flag | Description | | ------------------------------------------ | --------------------------------------------------------------------- | | `--archived` | List archived deals instead of active ones | | `--status` \ | Filter by status | | `--stage` \ | Filter by stage ID | | `--pipeline` \ | Filter by pipeline ID | | `--owner` \ | Filter by owner (user) ID | | `--person` \ | Filter by person ID | | `--org` \ | Filter by organization ID | | `--filter` \ | Filter by saved filter ID | | `--ids` \ | Comma-separated IDs to fetch (max 100) | | `--sort-by` \ | Sort field | | `--sort-direction` \ | Sort direction | | `--updated-since` \ | Only items updated at/after this RFC3339 time (no fractional seconds) | | `--updated-until` \ | Only items updated before this RFC3339 time (no fractional seconds) |
```bash
pdcli deal list
pdcli deal list --status won --limit 50
pdcli deal list --stage 3 --output json
```
### `pdcli deal participant add` [Section titled “pdcli deal participant add”](#pdcli-deal-participant-add) Add a participant (person) to a deal
```text
pdcli deal participant add [flags]
```
| Flag | Description | | ------------------- | ----------- | | `--person` \ | Person ID |
```bash
pdcli deal participant add 42 --person 10
pdcli deal participant add 42 --person 10 --output json
```
### `pdcli deal participant list` [Section titled “pdcli deal participant list”](#pdcli-deal-participant-list) List participants of a deal
```text
pdcli deal participant list [flags]
```
```bash
pdcli deal participant list 42
pdcli deal participant list 42 --output json
```
### `pdcli deal participant remove` [Section titled “pdcli deal participant remove”](#pdcli-deal-participant-remove) Remove a participant from a deal
```text
pdcli deal participant remove [flags]
```
| Flag | Description | | ------------------------ | ---------------------------- | | `--participant` \ | Deal-participant ID | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli deal participant remove 42 --participant 3
pdcli deal participant remove 42 --participant 3 --yes
```
### `pdcli deal product add` [Section titled “pdcli deal product add”](#pdcli-deal-product-add) Attach a product to a deal
```text
pdcli deal product add [flags]
```
| Flag | Description | | --------------------------------------- | ---------------------- | | `--product` \ | Product ID | | `--price` \ | Item price (per unit) | | `--quantity` \ | Quantity | | `--discount` \ | Discount value | | `--discount-type` \ | Discount type | | `--tax` \ | Product tax percentage | | `--comments` \ | Comments |
```bash
pdcli deal product add 42 --product 10 --price 90
pdcli deal product add 42 --product 10 --price 90 --quantity 3
pdcli deal product add 42 --product 10 --price 90 --discount 10 --discount-type percentage
```
### `pdcli deal product list` [Section titled “pdcli deal product list”](#pdcli-deal-product-list) List products attached to a deal
```text
pdcli deal product list [flags]
```
| Flag | Description | | ----------------------------------------------------- | ---------------- | | `--sort-by` \ | Field to sort by | | `--sort-direction` \ | Sort direction |
```bash
pdcli deal product list 42
pdcli deal product list 42 --sort-by add_time --sort-direction desc
pdcli deal product list 42 --output json
```
### `pdcli deal product remove` [Section titled “pdcli deal product remove”](#pdcli-deal-product-remove) Remove a product attached to a deal
```text
pdcli deal product remove [flags]
```
| Flag | Description | | ----------------------- | ---------------------------- | | `--attachment` \ | Deal-product (attachment) ID | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli deal product remove 42 --attachment 3
pdcli deal product remove 42 --attachment 3 --yes
```
### `pdcli deal product update` [Section titled “pdcli deal product update”](#pdcli-deal-product-update) Update a product attached to a deal (v2 PATCH — only provided fields change)
```text
pdcli deal product update [flags]
```
| Flag | Description | | --------------------------------------- | ---------------------------- | | `--attachment` \ | Deal-product (attachment) ID | | `--product` \ | Product ID | | `--price` \ | Item price (per unit) | | `--quantity` \ | Quantity | | `--discount` \ | Discount value | | `--discount-type` \ | Discount type | | `--tax` \ | Product tax percentage | | `--comments` \ | Comments |
```bash
pdcli deal product update 42 --attachment 3 --quantity 5
pdcli deal product update 42 --attachment 3 --price 120
pdcli deal product update 42 --attachment 3 --discount 15 --discount-type amount
```
### `pdcli deal summary` [Section titled “pdcli deal summary”](#pdcli-deal-summary) Summary of open/won/lost deals, totalled per currency
```text
pdcli deal summary [flags]
```
| Flag | Description | | ----------------------------- | ------------------------- | | `--status` \ | Filter by status | | `--pipeline` \ | Filter by pipeline ID | | `--stage` \ | Filter by stage ID | | `--filter` \ | Filter by saved filter ID |
```bash
pdcli deal summary
pdcli deal summary --status open --pipeline 1
pdcli deal summary --output json
```
### `pdcli deal update` [Section titled “pdcli deal update”](#pdcli-deal-update) Update a deal (v2 PATCH — only provided fields change)
```text
pdcli deal update [flags]
```
| Flag | Description | | -------------------------------- | -------------------------------------------------- | | `--title` \ | Deal title | | `--value` \ | Deal value | | `--currency` \ | Deal currency (e.g. EUR) | | `--status` \ | Deal status | | `--stage` \ | Stage ID | | `--pipeline` \ | Pipeline ID | | `--person` \ | Linked person ID | | `--org` \ | Linked organization ID | | `--owner` \ | Owner (user) ID | | `--probability` \ | Success probability (0-100) | | `--expected-close-date` \ | Expected close date (YYYY-MM-DD) | | `--field` \ | Custom/standard field as "Name=Value" (repeatable) | | `--body` \ | Raw JSON body to merge (flags win) |
```bash
pdcli deal update 42 --stage 5
pdcli deal update 42 --status won
pdcli deal update 42 --field "Deal Size=Large"
```
### `pdcli deal upsert` [Section titled “pdcli deal upsert”](#pdcli-deal-upsert) Idempotent deal upsert: match by --by, then create or PATCH only the changed fields. Refuses (exit 65) if more than one record matches.
```text
pdcli deal upsert [flags]
```
| Flag | Description | | ------------------ | ------------------------------------------------ | | `--by` \ | Match field: title, or a searchable custom field | | `--field` \ | Field to set as "Name=Value" (repeatable) | | `--body` \ | Raw JSON body to merge | | `--dry-run` | Preview the action without writing |
```bash
pdcli deal upsert "Acme expansion" --by title --field "Stage=Won"
pdcli deal upsert "D-42" --by "External ID" --body '{"value":5000}'
pdcli deal upsert "Acme expansion" --by title --field "Stage=Won" --dry-run
```
## pdcli field [Section titled “pdcli field”](#pdcli-field) ### `pdcli field create` [Section titled “pdcli field create”](#pdcli-field-create) Create a custom field on an entity
```text
pdcli field create [flags]
```
| Flag | Description | | -------------------- | ------------------------------------------------------ | | `--name` \ | Field name (label) | | `--type` \ | Field type (e.g. varchar, double, monetary, enum, set) | | `--options` \ | Comma-separated option labels (required for enum/set) |
```bash
pdcli field create deal --name "Budget" --type double
pdcli field create person --name "Tier" --type enum --options "Gold,Silver,Bronze"
```
### `pdcli field delete` [Section titled “pdcli field delete”](#pdcli-field-delete) Delete a custom field (data stored on records is lost)
```text
pdcli field delete [flags]
```
| Flag | Description | | ----------- | ---------------------------- | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli field delete deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12
pdcli field delete deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12 --yes
```
### `pdcli field get` [Section titled “pdcli field get”](#pdcli-field-get) Show one field by human name or hashed key
```text
pdcli field get [flags]
```
```bash
pdcli field get deal "Deal Size"
pdcli field get deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12
```
### `pdcli field list` [Section titled “pdcli field list”](#pdcli-field-list) List fields for an entity, including custom-field hash keys
```text
pdcli field list [flags]
```
```bash
pdcli field list deal
pdcli field list person --output json
```
### `pdcli field option add` [Section titled “pdcli field option add”](#pdcli-field-option-add) Add an option to an enum/set custom field
```text
pdcli field option add [flags]
```
| Flag | Description | | ------------------ | ------------------------ | | `--label` \ | Label for the new option |
```bash
pdcli field option add deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12 --label "Critical"
```
### `pdcli field option remove` [Section titled “pdcli field option remove”](#pdcli-field-option-remove) Remove an option from an enum/set custom field (records lose the value)
```text
pdcli field option remove [flags]
```
| Flag | Description | | ------------------- | ----------------------------------- | | `--option` \ | Option ID to remove (see field get) | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli field option remove deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12 --option 4
```
### `pdcli field update` [Section titled “pdcli field update”](#pdcli-field-update) Update a custom field (field\_code and field\_type cannot change)
```text
pdcli field update [flags]
```
| Flag | Description | | ----------------- | ---------------------- | | `--name` \ | New field name (label) |
```bash
pdcli field update deal dcf558aac1ae4e8c4f849ba5e668430d8df9be12 --name "New name"
```
## pdcli file [Section titled “pdcli file”](#pdcli-file) ### `pdcli file delete` [Section titled “pdcli file delete”](#pdcli-file-delete) Delete a file
```text
pdcli file delete [flags]
```
| Flag | Description | | ----------- | ---------------------------- | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli file delete 5
pdcli file delete 5 --yes
```
### `pdcli file download` [Section titled “pdcli file download”](#pdcli-file-download) Download a file by ID
```text
pdcli file download [flags]
```
| Flag | Description | | ---------------- | ------------------------------------- | | `--out` \ | Path to write to (default: file name) |
```bash
pdcli file download 5
pdcli file download 5 --out ./report.pdf
```
### `pdcli file get` [Section titled “pdcli file get”](#pdcli-file-get) Get a file by ID
```text
pdcli file get [flags]
```
```bash
pdcli file get 5
pdcli file get 5 --output json
```
### `pdcli file list` [Section titled “pdcli file list”](#pdcli-file-list) List files
```text
pdcli file list [flags]
```
```bash
pdcli file list
pdcli file list --limit 50 --output json
```
### `pdcli file remote-link` [Section titled “pdcli file remote-link”](#pdcli-file-remote-link) Link an existing remote file (Google Drive) to an item
```text
pdcli file remote-link [flags]
```
| Flag | Description | | ---------------------------------- | ------------------------------------------------- | | `--deal` \ | Link to a deal ID | | `--org` \ | Link to an organization ID | | `--person` \ | Link to a person ID | | `--remote-id` \ | ID of the remote file (e.g. Google Drive file ID) | | `--remote-location` \ | Remote storage location |
```bash
pdcli file remote-link --deal 42 --remote-id 1AbC
pdcli file remote-link --person 9 --remote-id 1AbC --output json
```
### `pdcli file update` [Section titled “pdcli file update”](#pdcli-file-update) Update a file name and/or description
```text
pdcli file update [flags]
```
| Flag | Description | | ------------------------ | ---------------------------- | | `--name` \ | The visible name of the file | | `--description` \ | The description of the file |
```bash
pdcli file update 5 --name report.pdf
pdcli file update 5 --description "Signed contract"
```
### `pdcli file upload` [Section titled “pdcli file upload”](#pdcli-file-upload) Upload a file
```text
pdcli file upload [flags]
```
| Flag | Description | | ------------------- | --------------------------------- | | `--deal` \ | Associate with a deal ID | | `--person` \ | Associate with a person ID | | `--org` \ | Associate with an organization ID |
```bash
pdcli file upload ./report.pdf
pdcli file upload ./report.pdf --deal 42
```
## pdcli filter [Section titled “pdcli filter”](#pdcli-filter) ### `pdcli filter delete` [Section titled “pdcli filter delete”](#pdcli-filter-delete) Delete a filter
```text
pdcli filter delete [flags]
```
| Flag | Description | | ----------- | ---------------------------- | | `-y, --yes` | Skip the confirmation prompt |
```bash
pdcli filter delete 5
pdcli filter delete 5 --yes
```
### `pdcli filter get` [Section titled “pdcli filter get”](#pdcli-filter-get) Get a filter by ID
```text
pdcli filter get [flags]
```
```bash
pdcli filter get 5
pdcli filter get 5 --output json
```
### `pdcli filter list` [Section titled “pdcli filter list”](#pdcli-filter-list) List filters
```text
pdcli filter list [flags]
```
| Flag | Description | | ------------------------------------------------------------------- | -------------- | | `--type` \ | Filter by type |
```bash
pdcli filter list
pdcli filter list --type deals --output json
```
## pdcli goal [Section titled “pdcli goal”](#pdcli-goal) ### `pdcli goal list` [Section titled “pdcli goal list”](#pdcli-goal-list) List goals
```text
pdcli goal list [flags]
```
| Flag | Description | | --------------------- | ---------------------------- | | `--assignee` \ | Filter by assignee (user) ID | | `--type` \ | Filter by goal type name |
```bash
pdcli goal list
pdcli goal list --assignee 7 --type deals_won --output json
```
## pdcli lead [Section titled “pdcli lead”](#pdcli-lead) ### `pdcli lead convert` [Section titled “pdcli lead convert”](#pdcli-lead-convert) Convert a lead to a deal. The conversion runs as an async job; use --wait to poll until it finishes. On success the lead is deleted.
```text
pdcli lead convert [flags]
```
| Flag | Description | | ------------------------- | ---------------------------------------------------------- | | `--stage` \ | Stage ID for the new deal (a pipeline is inferred from it) | | `--pipeline` \ | Pipeline ID for the new deal (ignored when --stage is set) | | `--wait` | Poll the conversion status until it finishes | | `--timeout-secs` \ | Max seconds to poll when --wait is set |
```bash
pdcli lead convert adf21080-0e10-11eb-879b-05d71fb426ec
pdcli lead convert adf21080-0e10-11eb-879b-05d71fb426ec --stage 7
pdcli lead convert adf21080-0e10-11eb-879b-05d71fb426ec --wait
```
### `pdcli lead create` [Section titled “pdcli lead create”](#pdcli-lead-create) Create a lead
```text
pdcli lead create [flags]
```
| Flag | Description | | -------------------------------- | --------------------------------------- | | `--title` \