diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 41f23e3..5bd62fc 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -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' }, ], @@ -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' }, ], }, diff --git a/website/src/content/docs/automation/webhooks.mdx b/website/src/content/docs/automation/webhooks.mdx new file mode 100644 index 0000000..1b08495 --- /dev/null +++ b/website/src/content/docs/automation/webhooks.mdx @@ -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 ` 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:` (default `3000`). +2. Registers a **temporary** catch-all webhook (named `pdcli-listen:`) 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 ` 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 `. 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 ` 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. + +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/). +::: diff --git a/website/src/content/docs/guides/filters.mdx b/website/src/content/docs/guides/filters.mdx new file mode 100644 index 0000000..5f6a071 --- /dev/null +++ b/website/src/content/docs/guides/filters.mdx @@ -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 ` 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.