Skip to main content

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 PUTPATCH.

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, migration 0.5.82, editable under Settings → Pipedrive Integration). CSV of groups on v2; default deals,persons,organizations,pipelines,stages,dealFields. Remove a group (or clear the field) to roll back per environment without a deploy. Read via BcPipedriveConfig::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\APIException with a real HttpContext, so every existing catch works unchanged.
  • The normalisation, both directions, inside BcPipedriveApi: v2 deal reads are reshaped back to v1 (flat custom fields, nested person_id/org_id/user_id stubs, Y-m-d H:i:s UTC timestamps, is_deleted → status deleted); stages/pipelines re-emit the SDK's camelCase rows with is_deleted deliberately inverted onto activeFlag/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_idowner_id, drop creator_user_id, PATCH) before capture, so the payload spec asserts the real wire shape (§7.2) — captures now carry apiVersion.
  • §4.1 related objects: BcPipedriveApi::enrichDealsWithRelatedData() batch-fetches persons and organizations (v2 ids= 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: now BcPipedriveConfig::mailDropEmail() / dealCcEmail($dealId) (the deal-scoped address is derived as mail-drop + +deal<id>; a v1 deal still carrying its own cc_email wins). 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 in getDealsByFilter() 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/webhooks is absent from Pipedrive's official v2 OpenAPI spec, i.e. an undocumented route that could vanish without notice, while /v1/webhooks is documented and not deprecated. So the group ships disabled; enable it in pipedrive_api_v2_groups only if Pipedrive documents it. Note the PAYLOAD version is a separate concern from the transport: webhook subscriptions default to payload 2.0 since 2025-03-17 and our registered subscription already is version: "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_codekey, field_namename, options {id,label} pass through — they drive the runtime enum id↔label translation), edit_flag maps from is_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-only createFilter/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", migration 0.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, v2 POST /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-in task; 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. No owner_id is 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:

DateSourceNote
2025-12-31Pipedrive's own changelog and a staff reply in the developer forumAlready passed — and v1 still works for us
2026-07-31Make and Zapier both tell their users thisIf 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 callv2 exists?On the sunset list?Our move
/deals✅ 200⚠️ deprecatedMigrate
/deals/search✅ 200⚠️ deprecatedMigrate
/persons✅ 200⚠️ deprecatedMigrate
/persons/search✅ 200⚠️ deprecatedMigrate
/organizations✅ 200⚠️ deprecatedMigrate
/pipelines✅ 200⚠️ deprecatedMigrate
/stages✅ 200⚠️ deprecatedMigrate
/dealFields⚠️ 200, but reshaped— not listedMigrated — 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 listedv2 support built but OFF by default (undocumented route); stays on the documented, non-deprecated v1
/notes❌ 404— not listedStays v1 — no choice
/filters❌ 404— not listedNo longer called — setup stopped managing filters; the backfill full-scans without one
/users/find❌ 404— not listedStays v1 — no choice

Note the base path. v2 lives under https://api.pipedrive.com/api/v2/…, not /v2/…. The obvious-looking /v2/deals returns 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.

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:

LineExpression
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

v1v2
user_idowner_id
label (CSV string)label_ids (array)
deleted / active_flagis_deleted (negated)
visible_to (string)visible_to (integer)
various timestampsRFC 3339 — incl. stage_change_time, which the stage-sync anchor parses
PUT for updatesPATCH

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:

v1v2
key (the 40-char hash)field_code
namefield_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_idowner_id, labellabel_ids, pipelinepipeline_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_startadditional_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.

RouteCostsBuys
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 ownBoth versions supported upstream; new endpoints free later
Small v2 clientrecommended~100 lines of our own HTTP code to write and ownNo 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_id behind small accessors
  • Extend readPagination() to recognise next_cursor alongside 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_deleted onto our active_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:853 onto 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, PUTPATCH 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):

SubjectComparedResult
Pipelines1✅ zero differences
Stages5✅ zero differences
Deals200zero differences — related-object and custom_fields mapping confirmed correct
Persons200✅ zero differences
Deal fields58 of 73/74⚠️ the §4.7 reshape; all 25 custom hash keys match
Organizations200cc_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

RouteBuildPlus
Small v2 client (recommended)2–3 daysstaging soak
SDK upgrade3–5 daysstaging soak

Open:

  • The date (§1). Everything upstream of this is guesswork until Pipedrive confirms it.
  • What "retired stage" should mean once active_flag no longer exists (§4.5) — a product decision, not a technical one.
  • Whether to migrate /dealFields. Resolved 2026-07-29: migrated. v2 deletes fields by field CODE (not numeric id), and creates them via 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/pd hardcodes the v1 base. It already honours a PIPEDRIVE_API_BASE override, 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:

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.