-
Notifications
You must be signed in to change notification settings - Fork 0
docs: Filters-as-code + Webhooks guides #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| 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/). | ||
| ::: | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--intervalunit not stated in guideThe guide says "
--intervalsets 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 60and instead get 60 ms polling (near-continuous). The CLI flag is defined asFlags.integer({ description: 'Milliseconds between synthetic poll cycles', default: 10_000 })— the--helpoutput 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!