The Pipedrive field registry
Audience: developers & administrators · Scope:
#__configbox_external_pipedrive_fieldsand_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
/dealFieldson 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.
| Piece | File | Role |
|---|---|---|
| Fields table | migration updates/0.5.92.php | #__configbox_external_pipedrive_fields |
| Options table | same | #__configbox_external_pipedrive_field_options |
| Fields model | models/adminbcpipedrivefields.php | import, dropdown feed, getFieldByKey(), the audit |
| Options model | models/adminbcpipedrivefieldoptions.php | option upsert, dropdown feed, getOptionsForField() |
| Controllers | controllers/adminbcpipedrivefield{s,options}.php | the importFields list task |
| Views | views/adminbcpipedrivefield{,s}/, …fieldoption{,s}/ | hidden lists, embedded in the settings form |
| CLI | cli/cb_pipedrive_import_fields.php | the same import, plus --adopt-legacy |
| API wrappers | BcPipedriveApi::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.
| Setting | Where set | Blank means |
|---|---|---|
pipedrive_field_key_quote_serial | Settings → Pipedrive Integration | no 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_fields — UNIQUE (company_id, entity, field_key)
| Column | Notes |
|---|---|
company_id | which Pipedrive account this row came from — part of the identity, see below |
entity | deal | person | organization | product |
field_key | the API key (v2's field_code) — the value the settings store |
field_id | v1's numeric id; absent under v2, and only filter conditions ever used it |
name, field_type, is_custom, order_nr | Pipedrive-owned, refreshed on every import |
active_flag | cleared when an import no longer finds the field. Rows are never deleted |
published | admin-curated, preserved across imports; hides a field from the dropdowns |
#__configbox_external_pipedrive_field_options — UNIQUE (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_pipedriveisutf8mb4_unicode_520_ci, so a JOIN between them on a string column fails withIllegal mix of collationsuntil one side carries an explicitCOLLATE. 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.phpdeclares it. Migration + accessor is not enough. - Use
bcimportedchoice, never a plain requiredjoin. 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.
| List | Filters | Free-text search |
|---|---|---|
| Fields | Applies to · Type · Custom field · Pipedrive company ID | Field name · Field key (separate boxes) |
| Field options | Field (searchable) · Applies to · Custom field · Pipedrive company ID | Option label · Option ID |
| Organisations | Pipedrive company ID · Still in Pipedrive | Organisation · Organisation ID |
| Pipedrive Users | Active | Name · Email |
Core CSS hides the filter bar in every embedded list —
.cb-content .intra-listing .kenedo-filters { display:none }inassets/css/admin.css. That is a fair default for a list of a handful of child rows; it is not for these.custom.cssre-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. Regeneratecustom.min.csswithcssoafter 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
statusfield'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_customis copied down from the parent field on every import; seeupdates/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'smodelMethod. 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 literallystring, whileKenedoProperty::getWheres()compares the posted value against the property's own column. So picking "deal" in Applies to posted a row id, the listing ranentity = '<row id>', and the list came back empty — a filter that looked like it worked and silently hid everything.KenedoPropertyBcimportedoverrides both halves: the options are keyed on the distinct column values (read with oneSELECT DISTINCT, not a full model scan — the organisation registry is ~1,600 rows), and the comparison is=rather than the inheritedLIKE '%…%', which would otherwise matchdaterangewhen you pickeddate.- 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.KenedoPropertyBcimportedbooleantherefore offers "No" as the tokennoand 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 isbcimportedbooleanrather than the built-inpublished.)
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:
| Entity | Custom fields the integration reads or writes |
|---|---|
| Deal | 6 — quote serial, agent feedback status, agent feedback lost reason, Deal Type, contains alternatives, Is OOT |
| Person | 0 — created from name / email / org_id |
| Organization | 0 — read only to confirm a branch's organisation_id exists; sent as a plain org_id |
| Product | 0 — 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 atbcquotes.php:2017ontogetFieldByKey()/getOptionsForField(), which is the API call the registry was built to remove. - Decide whether live should set the Is-OOT field.
Related docs
- 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 state | imported rows | isRequired() | effect |
|---|---|---|---|
| nothing imported yet | 0 | false | the settings form saves — including the credential needed to run the first import |
| pipelines/stages imported | > 0 | true | a 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()callsparent::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.