Migrating the Pipedrive integration to API v2
Audience: developers & AI agents · Scope: the v1 → v2 migration — the endpoint surface, what breaks in our code, the phased rollout, and the test strategy that gates each phase · Status: BUILT 2026-07-29 — all six migrated groups default to v2 behind a per-group setting; staging soak + live canary (§7.4) remain before/at deploy · Last reviewed: 2026-07-29
TL;DR: Pipedrive is retiring API v1, but not all of it — of the twelve endpoints we call, seven
are on the sunset list, three have no v2 at all, and two are optional. The whole integration goes
through one chokepoint (BcPipedriveApi), so the version switch can live in a single file. The two
genuinely hard parts are that v2 drops related objects from responses (person_id becomes a bare
int, so the cold-quote flow loses the person/org/user data it emails on) and that custom fields move
into a nested custom_fields object (25 fields, 22 call sites, both read and write). Everything else
is renames, cursor pagination, and PUT→PATCH.
What was built (2026-07-29)
All four phases of §6 are implemented, collapsed into one changeset because the possible 2026-07-31 cutoff left no room for a phased rollout — but the per-phase gates were still run (see below), and the phased rollback is preserved via the setting:
- The switch:
pipedrive_api_v2_groups(Pipedrive settings extension table, migration0.5.82, editable under Settings → Pipedrive Integration). CSV of groups on v2; defaultdeals,persons,organizations,pipelines,stages,dealFields. Remove a group (or clear the field) to roll back per environment without a deploy. Read viaBcPipedriveConfig::useV2(). - The client:
BcPipedriveV2Client(§5's "small client" route) +BcPipedriveV2Response. Both speak the SDK's dialects: responses read like SDK responses, failures throw the SDK's own\Pipedrive\APIExceptionwith a realHttpContext, so every existing catch works unchanged. - The normalisation, both directions, inside
BcPipedriveApi: v2 deal reads are reshaped back to v1 (flat custom fields, nestedperson_id/org_id/user_idstubs,Y-m-d H:i:sUTC timestamps,is_deleted→ statusdeleted); stages/pipelines re-emit the SDK's camelCase rows withis_deleteddeliberately inverted ontoactiveFlag/active(§4.5 decided: "retired" == deleted; the import's vanished-row sweep stays as the second net). Writes translate v1-shaped payloads to the v2 wire shape (nesting,user_id→owner_id, dropcreator_user_id,PATCH) before capture, so the payload spec asserts the real wire shape (§7.2) — captures now carryapiVersion. - §4.1 related objects:
BcPipedriveApi::enrichDealsWithRelatedData()batch-fetches persons and organizations (v2ids=param, 100/request); owner names/emails come from the local users table (see the Users bullet below) — the cold-quote flow calls it after collecting deals; no N+1, no users-API call. - §4.6
cc_email: nowBcPipedriveConfig::mailDropEmail()/dealCcEmail($dealId)(the deal-scoped address is derived as mail-drop ++deal<id>; a v1 deal still carrying its owncc_emailwins). The per-deal API read is gone. - §4.3 guard:
resolveTargetOrgId()reads both shapes; still fails closed. - §4.8 pagination:
getAllDealsByFilter()paginates by cursor under v2; the old offset loop ingetDealsByFilter()now delegates to it. - Webhooks: built for v2 but OFF by default. A 'webhooks' group exists (v2 reads normalised to
the camelCase matcher shape, creates translated to snake_case with payload version pinned
2.0, v2 deletes) and the routes answer on our account — but/api/v2/webhooksis absent from Pipedrive's official v2 OpenAPI spec, i.e. an undocumented route that could vanish without notice, while/v1/webhooksis documented and not deprecated. So the group ships disabled; enable it inpipedrive_api_v2_groupsonly if Pipedrive documents it. Note the PAYLOAD version is a separate concern from the transport: webhook subscriptions default to payload2.0since 2025-03-17 and our registered subscription already isversion: "2.0", which is what the inbound processor requires — true on either transport version. - dealFields (added same day): migrated after all — probing showed v2 CAN create fields
(
POST /api/v2/dealFields,field_name/field_type/options[{label}]) and deletes them by field code (DELETE /api/v2/dealFields/{field_code}), which removes the numeric-id blocker. Reads are normalised (field_code→key,field_name→name, options{id,label}pass through — they drive the runtime enum id↔label translation),edit_flagmaps fromis_custom_field. - Filters: the NEED was eliminated, not ported (the Filters API is v1-only; v2 probes: POST 405,
GET 404). setupIntegration no longer creates filters — the cold-quotes filter had no runtime
consumer, and the backfill filter is now an optional accelerator that setup merely VERIFIES:
redoPipedriveData()falls back to a full v2 scan (getAllDealsCarryingField()) selecting on the serial field client-side. Proven equivalent on live: filter set == scan set, 34,744 deals each, same runtime (~37s). The v1-onlycreateFilter/copyFilter/condition-builder code and the four filter wrappers were removed (zero callers). - Users: runtime is API-free. The day-to-day lookups (RSM email → user id on every push,
owner id → name/email in the cold-quote flow) read a locally-maintained table
(
#__configbox_external_pipedrive_users, backend entity "Pipedrive Users", migration0.5.83). The v1 users API (no v2 exists; not sunset) only feeds the IMPORT (backend button,cb_pipedrive_import_users.php, and setup — all best-effort); the rows are hand-editable, so the list survives even a users-API retirement. An unknown email resolves to null → the existing fallback-RSM chain → a clean, logged failure — never a wrong owner on a deal. - Webhooks: management best-effort, receiving API-free. Subscription management stays on the
documented v1 endpoint but is wrapped so a failure logs exact MANUAL registration steps
(Pipedrive → Settings → Tools → Webhooks, version 2.0) instead of failing setup; receiving needs
no API at all. The ingress stamps
pipedrive_last_webhook_received_on(system var) as a durable, non-API health signal — setup reports it, the runbook checks it. - Notes: retired entirely — replaced by v2 ACTIVITIES (migration
0.5.84). Every Quote Follow-Up submission now logs one done activity on the deal (BcPipedriveApi::addActivity, v2POST /activities, 'activities' group with the v1 SDK as rollback) carrying the submitted status/stage/date summary plus the comment when given — Pipedrive's own definition ("any action you take to move your deals forward") covers agent follow-up feedback, and logging done activities retroactively is documented behaviour. The activity type defaults to the built-intask; create a custom "Quote Follow-Up" activity type in Pipedrive (Settings → Activities — the activityTypes API is read-only) and setup auto-detects + stores its key (pipedrive_activity_type_follow_up), keeping task statistics clean. Noowner_idis sent, so the activity is attributed to the API user and reps' completed-activity stats stay unpolluted. With this, the integration's runtime makes zero v1 API calls — v1 remains only in best-effort tooling (users import, webhook management, activity-type detection).
Gates run on 2026-07-29 (dev, live account, read-only): parity harness — pipelines 1/1, stages
5/5, deals 100/100, persons 100/100 all zero differences (organizations differ only on the
retired cc_email; dealFields only the known §4.7 reshape — both handled as above); pipeline+stage
import on v2 → byte-identical table rows; outbound-deal-payload e2e spec green on the v2 wire
shape; full Pipedrive e2e set run the same day.
Still open before this is done on live: the staging soak and the single live canary deal (§7.4), and confirming the real cutoff date with Pipedrive (§1) — unchanged advice.
The harness (§7.1) proved the deal mapping across 200 real deals before any code was written —
and turned up two things a spot check missed: /dealFields is reshaped, not versioned (§4.7), and
organizations' cc_email is retired (§4.6) — which turned out to be a company-wide constant we
were fetching per deal, so that read became config.
1. The deadline is unresolved
Public sources disagree, and the gap is the difference between a careful migration and an emergency:
| Date | Source | Note |
|---|---|---|
| 2025-12-31 | Pipedrive's own changelog and a staff reply in the developer forum | Already passed — and v1 still works for us |
| 2026-07-31 | Make and Zapier both tell their users this | If real, this is days away as of writing |
What our own account says (probed 2026-07-23 with the production token): every v1 endpoint we use
returns 200, and Pipedrive sends no Deprecation or Sunset headers on our traffic. So the
cutoff has not been enforced and we are not being signalled. That is reassuring, not conclusive —
absence of a header is not a promise.
Action before writing code: confirm the real date with Pipedrive support or the account's developer notifications. If it genuinely is 2026-07-31, this plan is too slow — switch to triage: patch only the forced endpoints (§2), skip the tooling, accept the debt.
2. The endpoint surface — and what is actually forced
This is a partial migration; a v1 client stays regardless. Availability probed directly against our
account; the sunset column is from Pipedrive's deprecation changelog (which covers activities, deals,
persons, organizations, products, pipelines, stages and itemSearch — nothing else).
| Endpoint we call | v2 exists? | On the sunset list? | Our move |
|---|---|---|---|
/deals | ✅ 200 | ⚠️ deprecated | Migrate |
/deals/search | ✅ 200 | ⚠️ deprecated | Migrate |
/persons | ✅ 200 | ⚠️ deprecated | Migrate |
/persons/search | ✅ 200 | ⚠️ deprecated | Migrate |
/organizations | ✅ 200 | ⚠️ deprecated | Migrate |
/pipelines | ✅ 200 | ⚠️ deprecated | Migrate |
/stages | ✅ 200 | ⚠️ deprecated | Migrate |
/dealFields | ⚠️ 200, but reshaped | — not listed | Migrated — reads normalised; create via v2 POST; delete by field CODE (no numeric id needed) |
/webhooks | ⚠️ answers, but absent from the official v2 OpenAPI spec | — not listed | v2 support built but OFF by default (undocumented route); stays on the documented, non-deprecated v1 |
/notes | ❌ 404 | — not listed | Stays v1 — no choice |
/filters | ❌ 404 | — not listed | No longer called — setup stopped managing filters; the backfill full-scans without one |
/users/find | ❌ 404 | — not listed | Stays v1 — no choice |
Note the base path. v2 lives under
https://api.pipedrive.com/api/v2/…, not/v2/…. The obvious-looking/v2/dealsreturns 404 and will send you down a blind alley.
The one that could have sunk this: the cold-quote flow pulls deals by saved filter, and the Filters
API is v1-only. If v2 /deals had dropped filter_id, that flow would have needed redesigning.
It still accepts it — verified against a real filter, rows returned.
3. Why this is tractable: one chokepoint
BcPipedriveApi (…/customization/system_overrides/BcPipedriveApi.php)
is a real chokepoint, not an aspirational one: 26 named wrapper methods, ~37 call sites, and nothing
in the codebase bypasses it to reach the SDK directly (verified — grep for ->getDeals()-> etc.
hits only that file). Its docblock sets that rule and the rule has held.
So the version switch lives in one file. Call sites keep calling BcPipedriveApi::getDealDetails() and
never learn which version answered — provided the wrapper normalises the response shape, which is
exactly what Phase 1 does.
4. What breaks
Ranked by pain. Line references are against master as of 2026-07-23.
4.1 Related objects are gone from responses — critical
v1 returns person_id as an object carrying name and email; v2 returns a bare integer (confirmed on
our data: dict in v1, int in v2). There is no include_fields substitute — the related record must
be fetched separately. Done naively that is an N+1 per deal, so it needs a batch-fetch-and-map. This is
design work, not a find-and-replace, and it sits in the flow that emails real RSMs.
Ten of the eleven reads are recoverable this way — person name/email and owner name/email are all
fetchable from /persons and /users. The eleventh, org_id->cc_email, is not: see §4.6.
Eleven reads in models/bcpipedrive.php:
| Line | Expression |
|---|---|
| 182, 183 | $deal->user_id->name / ->email |
| 193, 455, 456, 648 | $deal->person_id->email |
| 248, 262, 673 | $deal->person_id->value |
| 679 | $deal->person_id->name |
| 681 | $deal->org_id->cc_email |
4.2 Custom fields move into a nested object — critical
Our deals carry 25 custom fields, flat at the root in v1, nested under custom_fields in v2 with
values wrapped in their own object. Every read and every write payload changes shape — 22
fieldKey*() call sites. This is the core of the outbound deal push (deal-updates.md).
v1: "9a45911c…": "BC-12345"
v2: "custom_fields": { "9a45911c…": { "value": "BC-12345" } }
4.3 The DEV-org safety guard fails closed — high
BcPipedriveApi::resolveTargetOrgId() reads $data->org_id->value (BcPipedriveApi.php:497–498).
Under v2 that is a scalar, so the lookup yields null, which the guard treats as "not the DEV org" and
blocks. It fails safe — no live deal is ever at risk — but every write from dev and staging stops
until it is fixed. That guard is the only thing standing between a dev experiment and a real customer's
CRM record, and it lives in our code, not in Pipedrive; fix it before any v2 write path exists.
4.4 Field renames and type flips — high
| v1 | v2 |
|---|---|
user_id | owner_id |
label (CSV string) | label_ids (array) |
deleted / active_flag | is_deleted (negated) |
visible_to (string) | visible_to (integer) |
| various timestamps | RFC 3339 — incl. stage_change_time, which the stage-sync anchor parses |
PUT for updates | PATCH |
v2 stages also drop pipeline_name (we already join for it) and add days_to_rotten /
is_deal_rot_enabled; v2 pipelines drop active, selected and url_title.
4.5 A silent one: the stage import's active flag — high
models/adminbcpipedrivestages.php:415 reads $stage->activeFlag, which does not exist in v2. The
isset() guard then defaults every stage to active — no error, no log line, just a wrong value
written to our table. Our "row vanished → mark active_flag = 0" logic still catches genuine
retirement, so the damage is bounded, but the semantics need a deliberate decision rather than an
accident. See pipeline-stages.md.
4.6 cc_email is retired — high (found by the parity harness)
models/bcpipedrive.php:681 reads $deal->org_id->cc_email for the cold-quote prompts. v2
organizations do not carry cc_email at all — not renamed, retired: it is absent from every row, and
include_fields=cc_email is rejected with HTTP 400. v2 deals do not carry it either.
That looks like a blocker until you check the data: across all 1,584 organizations the field has
exactly one distinct value (betacalco2@pipedrivemail.com). It is the company-wide Pipedrive
mail-drop address, not per-organization data — we were making an API call per deal to read a constant.
Fix: make it a setting (or a constant on BcPipedriveConfig) and delete the read. That is strictly
better than the code we have today, independent of the migration.
4.7 /dealFields is reshaped, not versioned — high (found by the parity harness)
v2 /dealFields is not a versioned equivalent of the v1 resource — the columns are renamed and the
numeric id is gone:
| v1 | v2 |
|---|---|
key (the 40-char hash) | field_code |
name | field_name |
id (numeric) | (absent) |
| — | is_custom_field, is_writable, subfields |
The good news: all 25 of our custom fields are present in v2 with their hash keys unchanged,
so BcPipedriveConfig::fieldKey*() resolution survives — that was the real risk. Of the 73/74
definitions, 58 match; the ~15 that differ are the built-in renames already listed in §4.4
(user_id→owner_id, label→label_ids, pipeline→pipeline_id, …), plus is_archived changing
field_type from enum to boolean.
Any code reading ->key, ->name or ->id off a deal-field row must be updated; keying on the field
code is the portable choice, since it is the only identifier both versions share.
4.8 Pagination: offsets become cursors — medium
additional_data.pagination.next_start → additional_data.next_cursor. Well contained:
BcPipedriveApi::readPagination() (:655–673) is already written defensively across two shapes, so
adding a third is the pattern it was built for. A second, older loop at bcpipedrive.php:853–858 should
move onto the shared paginator at the same time.
5. The SDK question
We vendor pipedrive/pipedrive 4.0.10 (tracked in git, pinned ^4.0 in
data/customization/libs/composer.json). The current release is 17.3.0 and supports v1 and v2 side
by side under separate namespaces. PHP is not a constraint — it wants 8.1+, we run 8.2.
| Route | Costs | Buys |
|---|---|---|
| Upgrade the SDK (4.0.10 → 17.3.0) | Thirteen majors across a tracked vendor tree; every wrapper body rewritten; large diff, mostly in code we don't own | Both versions supported upstream; new endpoints free later |
| Small v2 client ← recommended | ~100 lines of our own HTTP code to write and own | No vendor upheaval; SDK 4.x stays for notes/filters/users; wrapper signatures unchanged, so no call site moves |
Recommendation: the small client. Every call already goes through our wrappers, so the SDK's main value — surface coverage — is value we don't use. The upgrade becomes worthwhile only if we expect to adopt many more endpoints; today we call twelve.
6. Strategy — four phases, each shippable
Ordering principle: make the code tolerate both shapes before switching anything, so no phase is a flag day and each can ship or roll back on its own. Read-only, low-blast-radius endpoints first; the outbound deal write last.
Phase 1 — Make the readers version-tolerant (no behaviour change)
Nothing switches to v2. Teach the wrapper to accept either shape and add the plumbing to choose a version per endpoint group. Safe to ship immediately, worth doing whatever the deadline turns out to be.
- Accept scalar-or-object for
org_id,person_id,user_idbehind small accessors - Extend
readPagination()to recognisenext_cursoralongside the two offset shapes - Add a per-endpoint-group base-URL setting, defaulting every group to v1
- Fix the guard at
BcPipedriveApi.php:497
Gate: full suite green with every group still on v1; zero diff in captured payloads.
Phase 2 — Flip pipelines and stages (read-only)
The safest real switch: read-only, low volume, and the import already surfaces warnings when something
looks wrong. Settle the active_flag semantics (§4.5) here, deliberately.
- Map
is_deletedonto ouractive_flag; decide explicitly what "retired" now means - Confirm the default pipeline/stage warnings still fire
Gate: import twice, diff our tables against the v1 import — expect zero rows changed.
Phase 3 — Flip deal fields and deal reads (shape change)
The custom_fields nesting lands here, read-side only, where a mistake is visible but harmless.
- Normalise nested custom fields back to flat keys inside the wrapper, so call sites are untouched
- Batch-fetch persons, orgs and users once per run; map by id instead of per-deal calls (§4.1)
- Move the older loop at
bcpipedrive.php:853onto the shared paginator
Gate: parity harness (§7.1) reports zero differences across a full filter sweep.
Phase 4 — Flip the deal write (last, deliberately)
Writes go last because they are the only step that can corrupt real CRM data. Payload nesting,
PUT→PATCH and the renamed fields all land together.
- Rebuild the outbound payload in v2 shape; assert it in capture mode before anything is sent
- Soak on staging against the DEV org for a full cycle before live
Gate: captured payloads reviewed by hand, then a live canary on a single DEV-org deal.
7. How we prove it works
The suite already covers this integration well — 26 specs touch Pipedrive, and capture mode asserts write flows without sending anything (testing.md). The gap: nothing compares v1 against v2, so that is the one new piece of tooling worth building.
7.1 The parity harness — built
docroot/cli/cb_pipedrive_api_parity.php (logic in models/bcpipedriveparity.php). It fetches the same
records through both API versions, normalises each side to one canonical shape, and reports every
difference. GET only — there is no write path in it and it must not grow one, so it is safe to point
at live, which is the only place the data is realistic enough for the comparison to be worth anything.
Exit code is 0 when the versions agree and 1 when they do not, so it can gate a rollout phase in CI.
php cb_pipedrive_api_parity.php # every subject
php cb_pipedrive_api_parity.php --subjects=stages,pipelines # just those
php cb_pipedrive_api_parity.php --max-records=200 --verbose # cap + show differing records
php cb_pipedrive_api_parity.php --subjects=deals --filter-id=2 # the set a real flow reads
The normalisers are the migration's field map, written once and executably. Each version's row is
mapped onto the same canonical keys, so §4.4's rename table exists as code rather than prose. When the
real migration maps v2 onto our internal shape it must agree with normaliseDeal() and friends; if it
does not, one of the two is wrong. That is what makes "zero differences" an objective pass.
Two deliberate choices that keep the report readable: timestamps are compared as instants, not
strings (v1 sends Y-m-d H:i:s UTC, v2 sends RFC 3339 — comparing raw strings would mark every row as
different), and fields one side simply does not carry are reported once per subject as a structural
fact, not once per record.
What it has already established
Run against live on 2026-07-23 (--max-records=200):
| Subject | Compared | Result |
|---|---|---|
| Pipelines | 1 | ✅ zero differences |
| Stages | 5 | ✅ zero differences |
| Deals | 200 | ✅ zero differences — related-object and custom_fields mapping confirmed correct |
| Persons | 200 | ✅ zero differences |
| Deal fields | 58 of 73/74 | ⚠️ the §4.7 reshape; all 25 custom hash keys match |
| Organizations | 200 | ❌ cc_email missing on every row — §4.6 |
The deals result is the load-bearing one: across 200 real deals, mapping v2's bare person_id/org_id/
owner_id and nested custom_fields onto our canonical shape reproduces v1 exactly. The two
failures are both genuine findings the harness surfaced, not harness bugs — and neither was visible in a
spot check of a single record, which is precisely why the tool exists.
7.2 Capture-mode payload assertions (already exists)
PIPEDRIVE_TEST=capture records outgoing payloads to tests/.captures/pipedrive.jsonl and sends
nothing. Because those assertions pin payload shape, they will fail loudly on the custom_fields
nesting — which is what we want: the change gets caught in the suite instead of in production. Expect to
update them deliberately, and treat any that don't fail as suspicious.
tests/specs/agent/outbound-deal-payload.spec.ts is the primary shape guard.
7.3 The existing end-to-end specs (already exists)
The follow-up, nudge and stage-sync specs exercise the real flows end to end and must stay green
throughout — they are the regression net for everything the parity harness cannot see (our own DB
writes, the landing page). Notably quote-stage-sync-behaviour, quote-nudge-due, the
quote-follow-up-* set, plus the two opt-in live smokes.
7.4 Staging soak, then a live canary
Staging runs the real crons against the DEV org for a full cycle; then one live canary deal before the write path is turned on generally. The org guard means a staging mistake cannot reach a production deal — and Phase 1 keeps that guard working under v2, which is why it is Phase 1.
8. Effort and open questions
| Route | Build | Plus |
|---|---|---|
| Small v2 client (recommended) | 2–3 days | staging soak |
| SDK upgrade | 3–5 days | staging soak |
Open:
- The date (§1). Everything upstream of this is guesswork until Pipedrive confirms it.
- What "retired stage" should mean once
active_flagno longer exists (§4.5) — a product decision, not a technical one. Whether to migrateResolved 2026-07-29: migrated. v2 deletes fields by field CODE (not numeric id), and creates them via/dealFields.POST /api/v2/dealFields— so the numeric-id dependency only survived in the filter-condition builder, which died with the filter machinery (see "What was built": the filter need was eliminated).tools/pdhardcodes the v1 base. It already honours aPIPEDRIVE_API_BASEoverride, so this is a one-line change whenever we want it — see api-access.md.
How the facts here were established
Endpoint availability, response shapes and filter_id support were probed directly against the live
Beta-Calco Pipedrive account on 2026-07-23 using read-only GET requests with the production token.
The §4.6 and §4.7 findings, and the parity table in §7.1, come from running
cb_pipedrive_api_parity.php against live on the same date; the cc_email constant was confirmed by
scanning all 1,584 organizations. Code references are line numbers in the master working tree.
The integration code itself was migrated on 2026-07-29 — see "What was built" at the top. Re-verify §2 and §7.1 before further changes by re-running the harness; Pipedrive's surface is moving, which is the reason the check is a script rather than a paragraph.
The canonical reference: the OpenAPI specs
Pipedrive publishes machine-readable OpenAPI specs — check these first when asking what exists in v2, before probing anything:
- v2: https://developers.pipedrive.com/docs/api/v1/openapi-v2.yaml
- v1: https://developers.pipedrive.com/docs/api/v1/openapi.yaml
The v2 spec's complete family list (2026-07-29): activities, activityFields, boards, dealFields, deals, itemSearch, leads (search), organizationFields, organizations, personFields, persons, phases, pipelines, productFields, products, projectFields, projectTemplates, projects, stages, tasks. Not in it: users, notes, filters, webhooks — which is why those stay v1.
Related docs
- README.md — the integration hub (flows, foundational layer)
- deal-updates.md — outbound field reference; the thing §4.2 changes
- testing.md — capture mode, the org guard, the test matrix
- api-access.md — the
tools/pdread-only helper - ../quote-follow-up/pipeline-stages.md — the stage/pipeline import §4.5 touches