Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions website/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export default defineConfig({
{ label: 'Full-account backup', slug: 'guides/backup' },
{ label: 'Sales analytics', slug: 'guides/analytics' },
{ label: 'Data-hygiene audit', slug: 'guides/audit' },
{ label: 'Filters as code', slug: 'guides/filters' },
{ label: 'The raw api escape hatch', slug: 'guides/api' },
{ label: 'Local mock endpoint', slug: 'guides/mock' },
],
Expand All @@ -135,6 +136,7 @@ export default defineConfig({
{ label: 'Exit codes', slug: 'automation/exit-codes' },
{ label: 'CI recipes', slug: 'automation/ci' },
{ label: 'GitHub Actions', slug: 'automation/github-actions' },
{ label: 'Webhooks & dev loop', slug: 'automation/webhooks' },
{ label: 'Cookbook', slug: 'automation/cookbook' },
],
},
Expand Down
151 changes: 151 additions & 0 deletions website/src/content/docs/automation/webhooks.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
---
title: Webhooks & the local dev loop
description: Manage Pipedrive webhooks and run a Stripe-style local receiver that registers a temporary webhook, verifies deliveries, and forwards them to your app.
---

Pipedrive can POST an event to a URL every time a record changes. pdcli gives you two
things: plain CRUD over your account's webhooks, and `webhook listen`, a local dev loop
that stands up a receiver, registers a throwaway webhook pointing at it, and prints and
forwards each delivery, the way `stripe listen` does.

Webhooks are a **v1** feature. pdcli talks to `/api/v1/webhooks`, and v1 supports
**create and delete only** — there is no update. To change a subscription, delete it and
create a new one.

## Managing webhooks

`webhook list` shows every webhook on the account, with its subscription URL, the
action/object pair it fires on, and whether it is active:

```bash
pdcli webhook list
pdcli webhook list --output json
```

`webhook create` registers one. A webhook subscribes to exactly **one action on one
object**, each of which may be a `*` wildcard. `--url` is where Pipedrive POSTs, and
must be a public **https** endpoint:

```bash
pdcli webhook create --url https://example.com/hook --event-action change --event-object deal
pdcli webhook create --url https://example.com/hook --event-action "*" --event-object "*"
```

- `--event-action` is one of `create`, `change`, `delete`, or `*`.
- `--event-object` is one of `activity`, `deal`, `person`, `organization`, `product`,
`lead`, `note`, `pipeline`, `stage`, `user`, and a handful more (`--help` lists them all),
or `*`.
- `--version` defaults to `2.0` (the v2 payload shape).
- `--name` labels the webhook. `--http-auth-user` and `--http-auth-password` protect your
endpoint with basic auth; the two go together, or pdcli rejects the pair.

`webhook delete <id>` removes one. It confirms first (skip with `-y`/`--yes`); declining
aborts with exit 1:

```bash
pdcli webhook delete 3
pdcli webhook delete 3 --yes
```

## The local dev loop: `webhook listen`

`webhook listen` is the fast path for building against webhooks without deploying anything.
In **tunnel mode** it:

1. Binds a local receiver on `127.0.0.1:<port>` (default `3000`).
2. Registers a **temporary** catch-all webhook (named `pdcli-listen:<uuid>`) pointing at
your public tunnel URL, with generated basic-auth credentials.
3. Pretty-prints every delivery, and optionally forwards the raw payload to your app.
4. Deletes the temporary webhook when you stop it.

```bash
# tunnel already running: ngrok http 3000 → https://abc123.ngrok.app
pdcli webhook listen --url https://abc123.ngrok.app --forward-to http://localhost:3000
```

pdcli does **not** bundle a relay. You provide the public tunnel (ngrok, cloudflared, or
anything that terminates https and forwards to your local port), and pass its URL as
`--url`. Pipedrive POSTs to the tunnel, the tunnel forwards to the pdcli receiver on
`--port`, and pdcli handles the delivery.

Each delivery prints one line in a terminal — a timestamp, the `entity.action` key, and the
record id. Piped or under `--output json`, every delivery is emitted as a JSON line
(NDJSON), one object per delivery, so you can pipe it straight into `jq` or a file. Add
`--forward-to <url>` to also POST the raw payload to your local app, exactly as it arrived.

### Filtering with `--events`

The temporary webhook is a catch-all, and pdcli filters client-side. `--events` takes a
comma-separated list of `entity.action` patterns; `*` is a wildcard on either side, and a
bare entity means every action on it:

```bash
pdcli webhook listen --url https://abc123.ngrok.app --events deal.change,person.*
```

`deal.change,person.*` prints deal changes and every person event. Omit `--events` to see
everything.

### Stopping and cleanup

Stop with Ctrl-C (`SIGINT`), or bound the run with `--once` (stop after the first delivery)
or `--max-events <n>`. However it stops, pdcli deletes the temporary webhook so nothing is
left pointing at a tunnel that is about to disappear.

If a previous run crashed before it could clean up, its orphaned `pdcli-listen:` webhook is
**swept on the next start**. The run id is unique per session and only stale, untracked
leftovers are removed, so two `webhook listen` sessions can run at once without deleting
each other's live webhook.

### Why forged deliveries can't slip through

A public tunnel URL is guessable, so the receiver does not trust every POST that reaches it.
The temporary webhook is registered with **generated basic-auth credentials**, and the
receiver **rejects with 401** any POST that does not carry them. Only Pipedrive was handed
those credentials, so a forged request to your tunnel is never printed and never forwarded.

## `--synthetic`: the change feed as webhook deliveries

Sometimes you cannot receive an inbound webhook at all — you are behind a firewall, on a
laptop with no tunnel, or writing a reactive agent that only makes **outbound** calls.
`--synthetic` covers that case. Instead of registering a real webhook, it polls the
incremental change feed and emits the **same delivery envelope** a webhook would, with zero
inbound network:

```bash
pdcli webhook listen --synthetic --since 15m
pdcli webhook listen --synthetic --since 7d --events deal.change --forward-to http://localhost:3000
```

Each cycle fetches everything changed across deals, persons, organizations, activities, and
products since the last watermark, and emits one event per record. The event envelope is
`{ event, meta, current, previous }` (`previous` is always `null` — the feed carries only
current state). `--events`, `--forward-to`, and the JSON/NDJSON output all behave exactly
as they do in tunnel mode, so code written against synthetic deliveries also handles real
ones.

`--synthetic` keeps its **own watermark**, separate from the [`changes`](/pdcli/reference/commands/)
command's. The first run needs a `--since <timestamp|Nd>` to anchor from (a bare first run
with no stored watermark exits 64); after that it resumes on its own. `--interval` sets the
gap between poll cycles (default 10s), and `--once` runs a single cycle then exits.

:::tip
Two modes, same envelope, different trade-offs:

- **Tunnel mode** (`--url`) delivers **real** Pipedrive events the instant they fire, but
needs a public https tunnel you provide.
- **`--synthetic`** makes **no inbound connection** at all — it polls — so it works behind a
firewall and for outbound-only agents, at the cost of up-to-`--interval` latency and the
five v2 entities the change feed covers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 --interval unit not stated in guide

The guide says "--interval sets the gap between poll cycles (default 10s)" without mentioning that the value is in milliseconds. A reader who wants a 60-second interval will naturally try --interval 60 and instead get 60 ms polling (near-continuous). The CLI flag is defined as Flags.integer({ description: 'Milliseconds between synthetic poll cycles', default: 10_000 }) — the --help output shows the unit, but the guide should surface it too so examples and prose are self-contained.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code


Prototype an integration with `--synthetic`, then flip to `--url` for live deliveries once a
tunnel is up. See the [analytics guide](/pdcli/guides/analytics/) and the
[agent quickstart](/pdcli/start/agents/) for more on driving pdcli from scripts and agents.
:::

:::caution
The tunnel is **yours to provide**. pdcli does not ship or host a relay, and it will not
open one for you. `--url` must be a public https endpoint (ngrok, cloudflared, or similar)
that forwards to the receiver on `--port`. If the port is already in use, the receiver
fails to bind and pdcli exits 78 — see [exit codes](/pdcli/automation/exit-codes/).
:::
159 changes: 159 additions & 0 deletions website/src/content/docs/guides/filters.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
---
title: Filters as code
description: Provision Pipedrive saved filters from JSON — a server-side filter you reference by id on every list command, git-diffable and portable across accounts.
---

A Pipedrive **saved filter** lives on the server and gets referenced by id on nearly
every list endpoint — `pdcli deal list --filter 9`, `pdcli person list --filter 12`, and
the [bulk flows](/pdcli/guides/bulk/) all take the same id. That makes a filter the right
place to push selection logic: instead of paging every record and running client-side jq
over the results, you let Pipedrive apply the predicate and return only the matches.

Under pdcli's token budget that difference is real. A filtered list is one cheap request;
a scan-then-filter is many paginated GET-lists you pay for and throw most of away. And
because a filter is just JSON, it is git-diffable, reviewable, and portable — author it
once, `filter export` it, and recreate it on another account.

The `filter` commands make filters authorable as code:

```bash
pdcli filter list --type deals # discover existing filters + their ids
pdcli filter get 5 # inspect one
pdcli filter helpers # discover valid operators per field type
pdcli filter create --name … --type … --conditions …
pdcli filter update 5 --conditions …
pdcli filter export 5 # emit portable {name, type, conditions} JSON
```

## Creating a filter

`filter create` needs a `--name`, a `--type`, and a `--conditions` JSON blob:

```bash
pdcli filter create --name "Open deals" --type deals --conditions @conditions.json
cat conditions.json | pdcli filter create --name "Open deals" --type deals
```

`--type` is one of `deals`, `leads`, `org`, `people`, `products`, `activity`, or
`projects`. `--conditions` is resolved like every other body in pdcli — an inline string,
an `@file`, or piped stdin (see [`resolveBody`](/pdcli/guides/api/)). It is parsed as JSON
before any request; invalid JSON fails fast with exit **65** and never hits the API.

### The conditions shape

This is the part worth reading twice. `conditions` is a free-form nested JSON blob, but
Pipedrive imposes a **mandatory two-level glue structure**: an outer group whose
`conditions` array holds inner groups, and each inner group's `conditions` array holds the
actual leaf predicates. Every group carries a `glue` of `"and"` or `"or"`.

```json
{
"glue": "and",
"conditions": [
{
"glue": "and",
"conditions": [
{ "object": "deal", "field_id": 2384, "operator": "=", "value": "open" }
]
},
{
"glue": "or",
"conditions": [
{ "object": "deal", "field_id": 2391, "operator": ">", "value": 5000 }
]
}
]
}
```

Two things that trip people up:

- There is a **16-condition cap** across the whole blob — Pipedrive rejects filters with
more leaf predicates than that.
- Leaf conditions reference a field by its **numeric `field_id`**, not the 40-char
custom-field key hash that pdcli resolves for you everywhere else (see
[Custom fields](/pdcli/guides/custom-fields/)). The filters API predates that
resolution and speaks raw numeric ids only, so there is no name-to-key convenience here.

To discover the numeric field ids and which operators a field accepts, reach for
`filter helpers` (below) and `filter get` on an existing filter of the same type — copying
a working blob is the fastest way to learn the grammar.

## Discovering operators: `filter helpers`

`filter helpers` hits `GET /filters/helpers` and lists the operators available for each
field **data type** — `varchar`, `date`, `int`, `enum`, and so on. It answers "which
operators can I put in a leaf for a text field vs a date field?":

```bash
pdcli filter helpers # every data type
pdcli filter helpers --type varchar # just text-field operators
pdcli filter helpers --output json # machine-readable
```

`--type` narrows the output to a single data type. It is a **client-side** filter on the
data-type key of the response — there is no server-side type parameter — so an unknown
value simply yields no rows.

## Updating a filter

`filter update <id>` changes a filter in place. Only the fields you pass change:

```bash
pdcli filter update 5 --name "Renamed filter"
pdcli filter update 5 --conditions @conditions.json
```

Pass `--name`, `--conditions`, or both. `--conditions` is resolved and JSON-validated the
same way `create` does it (bad JSON → exit **65**). If you pass **neither**, there is
nothing to change and the command exits **64** rather than issuing an empty write.

## Exporting and recreating: `filter export`

`filter export` reduces a full filter record to the portable triple `name`, `type`,
`conditions` — the shape you need to recreate it — and prints it as pretty JSON:

```bash
pdcli filter export 5 > filter.json # one filter
pdcli filter export --all > filters.json # every filter, as a JSON array
```

Provide a filter id **or** `--all`; with neither it exits **64**. `--jq` still applies, so
exports can be sliced inside a pipeline.

Because `filter create` takes **three separate flags** (`--name` / `--type` /
`--conditions`), not one combined blob, recreating an exported filter on another account
means slicing the export back into those flags. This round-trip mirrors what the command's
own examples show:

```bash
pdcli filter export 5 > filter.json

pdcli filter create \
--name "$(jq -r .name filter.json)" \
--type "$(jq -r .type filter.json)" \
--conditions "$(jq -c .conditions filter.json)"
```

:::caution
The conditions grammar is **under-documented upstream** and references fields by **numeric
`field_id`**, not the human names or hash keys pdcli resolves elsewhere. A field id valid
on one account is meaningless on another, so a `conditions` blob is **not** blindly
portable: when you recreate a filter on a different account, re-point each `field_id` to
the target account's ids (list them with `pdcli field list` and confirm operators with
`filter helpers`). Copy a working `filter get` blob rather than authoring one from scratch.
:::

## Where filter ids flow

A filter id is a reusable handle. Once created, feed it to any list command's `--filter`
flag and to the [bulk flows](/pdcli/guides/bulk/):

```bash
pdcli deal list --filter 9 --sort-by update_time # preview the set
pdcli deal list --filter 9 --jq '.[].id' | pdcli deal bulk-update --stage 5
```

For anything the two-level glue grammar can't express — an endpoint or query pdcli's
filter commands don't wrap — drop down to the host-locked
[raw `api` escape hatch](/pdcli/guides/api/) and call `/api/v1/filters` directly.
Loading