Skip to main content

The Pipedrive field registry

Audience: developers & administrators · Scope: #__configbox_external_pipedrive_fields and _field_options — the import, the settings that reference them, and the config audit · Last reviewed: 2026-08-07

TL;DR: A Pipedrive custom field is addressed by a 40-character hash and its options by numeric ids, and both are per account. Those values used to be typed into free-text settings — and two of them were PHP constants — so an environment could not be pointed at a different Pipedrive account without editing code. The registry imports every field and option into two local tables; the settings became dropdowns over them. Import with cli/cb_pipedrive_import_fields.php, which also prints the config audit: which configured field no longer resolves.

What it is & why it exists

Ask what "quote serial" means in Pipedrive and the answer is a1ce5834… on live and 0e1c62fa… in the sandbox. Ask what "Deal Type = Test" means and it is 161 on live, 58 in the sandbox. Nothing about either value is guessable, transferable, or checkable — and until this existed, six of them were configuration and two were source code:

const FIELD_KEY_DEAL_TYPE = 'd81a3a921fa2922231b32ddca8861f7f1a86f86d'; // live's hash, in a class
const DEAL_TYPE_TEST = '161';

That is why the developer sandbox sat unused for so long. Pointing an environment at it was not a configuration change; it was a code change and a deploy.

Three things fall out of importing them instead:

  • The settings become a choice, from this account's own fields, per environment. One codebase serves both accounts.
  • The configuration can be audited. Every import reports which configured field this account does not have — which is how we learned that live has never set the Is-OOT field at all.
  • Two API calls leave the hot path. Turning an option id into a label used to re-read /dealFields on every inbound webhook change and every outbound deal push.

How it fits together

Fields are the parent, options the child — deliberately the same relationship pipelines have with stages, so everything already true of those lists is true here.

PieceFileRole
Fields tablemigration updates/0.5.92.php#__configbox_external_pipedrive_fields
Options tablesame#__configbox_external_pipedrive_field_options
Fields modelmodels/adminbcpipedrivefields.phpimport, dropdown feed, getFieldByKey(), the audit
Options modelmodels/adminbcpipedrivefieldoptions.phpoption upsert, dropdown feed, getOptionsForField()
Controllerscontrollers/adminbcpipedrivefield{s,options}.phpthe importFields list task
Viewsviews/adminbcpipedrivefield{,s}/, …fieldoption{,s}/hidden lists, embedded in the settings form
CLIcli/cb_pipedrive_import_fields.phpthe same import, plus --adopt-legacy
API wrappersBcPipedriveApi::fieldResources() / getAllFieldsForEntity()all four entities in one v1-shaped response

Both lists are reachable only through the settings form (their metadata.xml declares <view hidden="true" />), like the pipelines, stages and users lists.

Admin settings

Every setting below is per environment, on #__configbox_external_settings_pipedrive, and is a bcimportedchoice dropdown over the registry. All of them may be blank — a blank key simply skips that field on the deal push.

SettingWhere setBlank means
pipedrive_field_key_quote_serialSettings → Pipedrive Integrationno serial written — the deal cannot be found again
pipedrive_field_key_agent_feedback_status"agent's suggested status not written
pipedrive_field_key_agent_feedback_lost_reason"lost reason not written
pipedrive_field_name_is_oot"the OOT flag is not pushed (this is live's current state)
pipedrive_oot_option_yes / _no"as above
pipedrive_field_key_deal_type"falls back to the FIELD_KEY_DEAL_TYPE constant
pipedrive_deal_type_option_regular / _internal / _test"fall back to 159 / 160 / 161
pipedrive_field_key_has_alternatives"falls back to FIELD_KEY_HAS_ALTERNATIVES
pipedrive_has_alternatives_option_yes / _no"fall back to 173 / 174

The constants are LIVE's values. So the fallback is only ever correct on live. Any other environment must set these explicitly, and the audit says so after every import. Remove the constants once every environment reports zero fallbacks.

Two lists are embedded in the same settings group as read-only tables: Imported Pipedrive Fields and Imported Pipedrive Field Options.

Data model

#__configbox_external_pipedrive_fieldsUNIQUE (company_id, entity, field_key)

ColumnNotes
company_idwhich Pipedrive account this row came from — part of the identity, see below
entitydeal | person | organization | product
field_keythe API key (v2's field_code) — the value the settings store
field_idv1's numeric id; absent under v2, and only filter conditions ever used it
name, field_type, is_custom, order_nrPipedrive-owned, refreshed on every import
active_flagcleared when an import no longer finds the field. Rows are never deleted
publishedadmin-curated, preserved across imports; hides a field from the dropdowns

#__configbox_external_pipedrive_field_optionsUNIQUE (company_id, entity, field_key, option_id), carrying option_id, label, order_nr, active_flag.

option_id is a VARCHAR, not an int. Custom enums use numbers, but Pipedrive's built-in enums use words — the deal status field offers open/won/lost — and an int cast folds every one of those onto 0, which collides on the unique key.

Why company_id is part of the identity. Each environment has its own database, so in principle the table already means "this environment's account". In practice tools/sync_dev_from_live.sh restores live's database onto dev. Keying on the company means a synced-down live registry does not match dev's configured company and is simply re-imported, instead of silently resolving dev's settings to live's hashes. Both tables hold both accounts happily — live's 191 rows and the sandbox's 160 coexist, and the credential decides which set the dropdowns offer.

Collation: both tables are explicitly utf8mb4_unicode_ci, the CBX standard. Their neighbour ..._settings_pipedrive is utf8mb4_unicode_520_ci, so a JOIN between them on a string column fails with Illegal mix of collations until one side carries an explicit COLLATE. No shipped code does that (the models compare against PHP values), but it will catch you writing an ad-hoc query. See _known-issues.md #39.

Running the import

php docroot/cli/cb_pipedrive_import_fields.php
php docroot/cli/cb_pipedrive_import_fields.php --adopt-legacy=1

Or the Import fields & options from Pipedrive button on the Imported Pipedrive Fields list. Both do the same thing: read all four entities, upsert on (company_id, entity, field_key), preserve published, mark anything Pipedrive no longer returns active_flag = 0, and import the options in the same pass. Idempotent — a second run reports 0 new.

Scheduled daily at 06:10 alongside the other imports; see scheduled-jobs.md.

--adopt-legacy=1

Makes this environment's implicit configuration explicit: writes the Deal Type and contains-alternatives settings from the old constants, and resolves an Is-OOT setting that holds a field name into its key. It only ever fills blanks.

Crucially, it refuses to adopt a value this account does not have, and says so per setting:

# against the sandbox
Left pipedrive_deal_type_option_test blank: the value it used to be
hardcoded to (161) does not exist in this Pipedrive account.

# against live
Adopted pipedrive_deal_type_option_test = 161 (was a hardcoded constant).

That refusal is the point: adopting live's 161 into a sandbox-pointed environment would replace an honest blank with a value Pipedrive is guaranteed to reject.

The config audit

Every import ends with getDefaultsWarnings() — one plain sentence per configured field that does not resolve:

Warning: Quote serial field points at a field this Pipedrive account does not have (a1ce5834…).
Warning: Deal Type field is not set, so that field is not written to Pipedrive at all.

Read it. The second kind is not an error — a blank key is legitimate — but a silently skipped field is exactly the sort of thing nobody notices for years. It is how we found that production does not push the Is-OOT flag at all.

Gotchas & caveats

  • A new settings column is invisible until it has a prop def. Kenedo builds the settings SELECT from the property definitions, so a column added by a migration reads as empty until models/adminbcsettings.php declares it. Migration + accessor is not enough.
  • Use bcimportedchoice, never a plain required join. A required join renders an empty <select> when nothing has been imported, posts nothing, fails validation — and locks the admin out of saving any setting, including the credential the import needs. That trap is why the type exists.
  • An option must belong to its field, enforced on save by ConfigboxModelAdminbcsettings::validateData(). The option dropdowns are one flat list across every enum field (grouped per field), exactly as the stage list spans every pipeline, so picking from the wrong group is easy.
  • API v2 returns no field order_nr, so the dropdowns sort by row position and the options query orders by field name — otherwise options from different fields interleave and the optgroups render the same field several times.
  • Built-in fields are imported but not offered. The dropdowns list custom fields only; a built-in is addressed by its plain name in the payload, never by a key.

Finding things in the lists

Both lists are read-only and long — a real account imports ~190 fields and ~250 options, and the registry deliberately holds both accounts at once, so the same field name appears twice.

ListFiltersFree-text search
FieldsApplies to · Type · Custom field · Pipedrive company IDField name · Field key (separate boxes)
Field optionsField (searchable) · Applies to · Custom field · Pipedrive company IDOption label · Option ID
OrganisationsPipedrive company ID · Still in PipedriveOrganisation · Organisation ID
Pipedrive UsersActiveName · Email

Core CSS hides the filter bar in every embedded list.cb-content .intra-listing .kenedo-filters { display:none } in assets/css/admin.css. That is a fair default for a list of a handful of child rows; it is not for these. custom.css re-shows it for the imported Pipedrive lists by name, with one class more than the core rule so it wins on specificity rather than load order. Regenerate custom.min.css with csso after editing it — CSS is served minified.

Two of those earn their keep:

  • Custom field = Yes. Options are imported for built-in enums too — the deal status field's open/won/lost, and so on — and on the live account those outnumber the custom ones 150 to 115. Nothing ever references a built-in option, so this filter removes more than half the noise. (is_custom is copied down from the parent field on every import; see updates/0.5.96.php.)
  • The Field filter is a searchable Chosen dropdown, and it lists only the fields that have options — ConfigboxModelAdminbcpipedrivefields::getFieldsWithOptions(), wired in as that property's modelMethod. About 47 entries instead of ~350: only enum/set fields produce options, so offering all of them would mostly offer choices ("Title", "Add time") that return an empty list — indistinguishable, to a reader, from the filter being broken.

Why the filters needed two property-type overrides

A dropdown filter over an import-owned column does not work out of the box, in two separate ways — both worth knowing before adding a filter to any other imported list:

  • KenedoProperty::getPossibleFilterValues() keys its options on the record's primary key, for every property type except literally string, while KenedoProperty::getWheres() compares the posted value against the property's own column. So picking "deal" in Applies to posted a row id, the listing ran entity = '<row id>', and the list came back empty — a filter that looked like it worked and silently hid everything. KenedoPropertyBcimported overrides both halves: the options are keyed on the distinct column values (read with one SELECT DISTINCT, not a full model scan — the organisation registry is ~1,600 rows), and the comparison is = rather than the inherited LIKE '%…%', which would otherwise match daterange when you picked date.
  • A boolean's "No" cannot survive the round trip. KenedoView::getFiltersFromUpdatedState() discards a filter whose value is 'all', '' or '0' — one branch for "no filter" and, unintentionally, for FALSE. KenedoPropertyBcimportedboolean therefore offers "No" as the token no and translates it back to = '0'. Without that, Custom field = No and Still in Pipedrive = No listed everything. (This is also why the Pipedrive Users list's Active flag is bcimportedboolean rather than the built-in published.)

Covered by tests/specs/backend/pipedrive-registry-lists.spec.ts, which also measures the filter bar's geometry — the input and its Filter button inherit different heights inside a container core caps at 200px, so without the custom.css block above every button drops onto a line of its own.

Organisations

#__configbox_external_pipedrive_organizations, imported by cli/cb_pipedrive_import_organizations.php or the button on its list.

It exists for one setting: pipedrive_org_id_dev, the organisation non-live environments may write deals for. That used to be a free-text 6034 — unguessable, unverifiable, and per account. It is a dropdown of real names now, so you pick "Beta Calco Development" rather than remember a number that means nothing in any other account.

Deliberately capped at 20 pages of 500. An account can hold tens of thousands of organisations and this is a picker, not a mirror; the import says so when it stops early, and skips the vanished-row sweep in that case — everything past the cap was never seen, so marking it inactive would be a lie. Production currently imports 1,584.

It is also the first step toward running the test suite against the sandbox. That is blocked on organisation ids — branch rows reference production organisations and the deal push validates them — and this list is what makes the mismatch visible instead of mysterious. See testing.md.

How many fields the integration actually needs

Six, all on deals — audited 2026-08-07 and worth knowing before anyone tries to reconcile two accounts' schemas:

EntityCustom fields the integration reads or writes
Deal6 — quote serial, agent feedback status, agent feedback lost reason, Deal Type, contains alternatives, Is OOT
Person0 — created from name / email / org_id
Organization0 — read only to confirm a branch's organisation_id exists; sent as a plain org_id
Product0 — the integration never touches products

Everything else the registry imports is context: useful in a dropdown, useful when reading a deal by hand, and irrelevant to the sync. This is why the sandbox's 30-field cap does not bind — see provisioning.md.

Possible follow-ups

  • Drop the FIELD_KEY_* / DEAL_TYPE_* / HAS_ALTERNATIVES_* constants once every environment reports zero fallbacks.
  • Route ConfigboxModelBcPipedrive::getDealFieldByKey() and the option-label translation at bcquotes.php:2017 onto getFieldByKey() / getOptionsForField(), which is the API call the registry was built to remove.
  • Decide whether live should set the Is-OOT field.
  • oauth-app.md — the credential layer that decides which account the registry describes
  • provisioning.md — creating the fields the registry names, in whichever account an environment points at
  • README.md — the integration's flows and foundational layer

"None selected" is a real option

Every bcimportedchoice dropdown offers an empty entry, first, whether or not something is stored. Two reasons, and the first is a bug that was live until 2026-08-21:

  • Without it, an unset setting adopts a value by itself. A <select> with no option matching the empty value shows the first real row as though it were chosen, and the next save of the form stores it. The admin never touched the field. For the DEV-organisation guard that meant silently pointing a write guard at whichever organisation happened to sort first.
  • Clearing a setting is legitimate. Live deliberately runs with a blank Is-OOT field, so the flag is not pushed at all. Without an empty option there is no way back to that state through the form.

The option carries value="", not the join template's own defaultlabel option, which hardcodes value="0" — every reader treats '' as unset and "0" as a real value.

A property that is genuinely required still refuses an empty save: isRequired() counts the imported rows (parent::getParentModelRecords(), deliberately not the override, which always includes the empty row) so a fresh install with nothing imported stays saveable, and an explicit validation error beats a silent adoption.

Conditionally required — and why that is load-bearing

pipedrive_default_pipeline_id and pipedrive_default_stage_id both declare 'required' => 1, but bcimportedchoice::isRequired() only enforces it once there is something to choose:

account stateimported rowsisRequired()effect
nothing imported yet0falsethe settings form saves — including the credential needed to run the first import
pipelines/stages imported> 0truea default must be picked; an empty save is refused with "Field Default stage cannot be empty."

Both halves matter and neither is optional:

  • Relaxed while empty, or a fresh install deadlocks: a required dropdown with no options posts nothing, fails validation, and the form can never be saved — so the import that would populate it can never be configured either.
  • Enforced once populated, because a quote's first push sends the default stage to Pipedrive (see deal-updates.md). An unset default there means every new deal lands in whatever pipeline the account defaults to.

The count must come from the IMPORTED rows. isRequired() calls parent::getParentModelRecords() deliberately — the override always prepends a "none selected" row, and counting that would make an empty list look populated and re-impose the requirement on a fresh install. It is a one-word difference between working and deadlocked, so it carries a comment saying so.

Covered by tests/specs/backend/pipedrive-registry-lists.spec.ts — one test that both defaults are set once options exist, and one that every imported dropdown offers an empty option first.