What gets updated on a Pipedrive deal — and when
Audience: developers, agents, support · Scope: primarily the outbound direction (website → Pipedrive): which website actions push to a deal, and exactly which deal fields are written — plus a reverse-direction summary of what syncs back · Last reviewed: 2026-08-24 · See README.md for the overall architecture and the inbound webhook.
Prefer pictures? pipedrive-data-map.html draws this doc's field mapping as one page — both directions, the org/person plumbing and the export triggers.
TL;DR: Many website actions flag a quote for a Pipedrive deal update by setting one column,
pipedrive_update_scheduled = 1. A cron job (cli/cb_pipedrive_update_deals.php, every minute)
picks up the flagged quotes and pushes one combined payload to the deal — creating the deal if it
doesn't exist yet, updating it otherwise. There is no separate "status-only" path: every push sends
the full picture (commercial content and status/feedback).
How the pipeline works
website action ──► scheduleDealUpdate(quoteId) ──► pipedrive_update_scheduled = 1
│
cron (every minute): cb_pipedrive_update_deals.php │
▼
executeScheduledDealUpdates() ──► updatePipedriveDeal(quoteId) ──► Pipedrive
(latest revisions only) (create-or-update, full payload)
│
on success: clear flag + stamp "last update sent"
- The runner only processes quotes with
is_latest_revision = 1. Flagging a superseded revision does nothing. - Batch cap per run (settings → Pipedrive Integration → "Deal updates per run", default 20,
migration
0.5.86): one tick processes at most that many quotes, oldest first; the rest wait for later ticks and the log saysBatch cap: processing N of M. Protects against backlog bursts (e.g. hundreds of quotes arriving pre-flagged with a DB copy) — at the default, a backlog drains at up to 1,200/hour instead of in one run. Raise the setting temporarily to drain faster. A server-wideGET_LOCK(DB-name-scoped) additionally keeps a slow run from overlapping the next cron tick. - On success the flag is cleared and
pipedrive_date_last_exportis stamped (shown as "Last update sent to Pipedrive" on the quote-maker quote-details page). - On failure the quote is logged and stays flagged, so it is retried on the next run — but only up
to 3 attempts (
pipedrive_update_attempts); after that it is skipped until a new website change reschedules it (which resets the counter). Success clears both the flag and the counter.
What schedules a deal update (the circumstances)
All of these just set the same flag; the payload that eventually goes out is identical regardless of which one triggered it.
Commercial / content changes
| Action (user-facing) | Where (method) |
|---|---|
| Add a line item to a quote | addPosition() |
| Edit a line item | editPosition() |
| Copy a line item | copyPosition() |
| Delete a line item | deletePosition() |
| Change a line item's quantity | updatePositionQuantity() |
| Toggle a line item's "alternative" flag | editPositionIsAltFlag() |
| (Re)price a position | setPositionPricing() |
| Add/store a position via the Quote Console | storePosition() (bcquoteconsole) |
| Override a line item's unit price / discount / overage (Quote Console) | updatePositionProperty() (bcquoteconsole) |
| Change a line item's commission (Quote Console) | updatePositionCommissionInfo() (bcquoteconsole) |
| Add a blank line item (Quote Console) | addBlankPosition() (bcquoteconsole) |
| Edit quote fields | editQuote() |
| Set / rename the project name (My Quotes) | storeProjectName() (bcmyquotes) |
| Duplicate a quote or create a new revision | copyQuote() |
| Delete a quote | deleteQuote() — deletes the deal (see special cases) |
Status / agent-feedback changes
| Action (user-facing) | Where (method) |
|---|---|
| Agent sets the quote status (open / won / lost) | updateAgentFeedbackStatus() |
| Agent sets the lost reason | updateAgentFeedbackLostReason() |
| A Pipedrive status change is applied to the quote | updatePipedriveDealStatus() |
| The out-of-territory (OOT) form is submitted | submitOotForm() (bcootform) |
| Agent submits the Quote Follow-Up landing page | saveUpdate() (bcquotelandingpage) → saveAgentUpdate() → updateAgentFeedbackStatus() (see dedicated section) |
Conditional triggers (only fire in specific cases)
| Action | Condition |
|---|---|
The inner schedule inside copyQuote() | only if a pipedrive_* value was passed as an override and the new quote already has a linked deal (the unconditional schedule at the end of copyQuote() still fires regardless) |
Note on
saveQuote(bcqmquotedetails): it always schedules an update — it callseditQuote(), which schedules unconditionally. The extrapipedrive_*-field check in the controller (bcqmquotedetails.php) is a redundant second flag, not the sole trigger. It is not a conditional trigger.
What gets written to the deal (the payload)
updatePipedriveDeal() builds a single payload. Custom fields (the right-hand "key" columns) are
only included when their key/name is configured in CB settings.
API v2 wire shape (since 2026-07-29). The model still builds the v1-style payload below;
BcPipedriveApitranslates it before sending (and before test capture) when thedealsgroup is on v2: custom fields nest undercustom_fields.<key>,user_idbecomesowner_id,creator_user_idis dropped (v2 derives it), updates go asPATCH /deals/{id}with the id on the URL. See api-v2-migration.md.
| Deal field | Value source | On create (addADeal) | On update (updateADeal) |
|---|---|---|---|
| Title | quote name, or the serial if unnamed | ✅ set | ❌ never re-titled |
Organization (org_id) | the quote's branch organisation | ✅ | ✅ |
| Value | discounted quote total | ✅ | ✅ |
| Currency | quote currency | ✅ | ✅ |
Owner (user_id) | RSM's Pipedrive user (see below) | ✅ | ✅ |
Contact person (person_id) | Pipedrive person for the quote user's billing email (created if missing) | ✅ | ✅ |
Creator (creator_user_id) | RSM's Pipedrive user | ✅ | ✅ |
| Quote Serial (custom) | quote.serial | ✅ | ✅ |
| Deal Type (custom) | derived → Test / Internal / Regular | ✅ | ✅ |
| Deal contains alternatives (custom) | derived → Yes / No | ✅ | ✅ |
| Status | pipedrive_status, only when it is open/won/lost | ✅ (when set) | ✅ (when set) |
| Lost reason | pipedrive_lost_reason when status is lost, else empty | ❌ stripped on create (Pipedrive rejects it) — a lost quote's addDeal is followed immediately by an updateDeal carrying the reason | ✅ (sent with status) |
| Agent Feedback Status (custom) | the option id matching the pipedrive_agent_feedback_status label (the agent's suggestion) | ✅ (if configured) | ✅ (if configured) |
| Agent Feedback Lost Reason (custom) | pipedrive_agent_feedback_lost_reason | ✅ (if configured) | ✅ (if configured) |
| Is OOT (custom) | Yes/No from oot_status == 'approved' | ✅ (if configured) | ✅ (if configured) |
stage_id + pipeline_id (native) | pipedrive_stage_id — any imported stage; its pipeline is sent alongside, so cross-pipeline moves work. On create, a quote with no stage gets the configured default stage + pipeline | ✅ (when a known imported stage is set, or the default) | ✅ (when a known imported stage is set) |
| Expected close date | pipedrive_agent_feedback_completion_date (Quote Follow-Up) | ✅ (when set) | ✅ (when set) |
| Follow-up activity (timeline entry, not a field) | pipedrive_activity_pending — set by EVERY follow-up submission; the queued comment (pipedrive_agent_feedback_comment_pending) folds into its note | ✅ (logged once after create) | ✅ (logged once, deduped) |
How the derived values are decided
- Deal Type — if the quote name contains "test" → Test (161); else if the user's billing
email is
@betacalco.com→ Internal (160); else → Regular (159). - Deal contains alternatives — Yes (173) if any position on the quote is flagged as an alternative, otherwise No (174).
- Owner / Creator (RSM) — looked up from the branch's
rsm_email. If the branch has no email, the fallback RSM (pipe@betacalco.com) is used. If the email isn't a Pipedrive user, it retries with the fallback. If even the fallback isn't a Pipedrive user, the update fails for that quote. - Contact person — found by the quote user's billing email; if no person exists, one is created under the branch's organisation.
- Agent Feedback Status option — the agent's suggested status
(
pipedrive_agent_feedback_status:open/won/lost) is matched against the option labels of the Agent Feedback Status custom field to find the option id to store. This is intentionally the agent's suggestion, not the deal's nativestatus: agents suggest won/lost/open, Beta Calco staff decide the actual deal status. (Foropen/lostthe two are equal anyway; only a suggestedwondiffers — it fills this field without touching the native Status. It also mirrors the inbound webhook, which writes this field back intopipedrive_agent_feedback_status.)
Create vs. update
updatePipedriveDeal() looks the deal up first — by the stored pipedrive_deal_id, and if that
fails, by the quote serial via the configured "deals with quote serial" filter.
- Deal found →
updateADeal. The title is left untouched;lost_reasonis allowed. - No deal →
addADeal. The title is set;lost_reasonis removed from the payload because Pipedrive's create endpoint rejects it. A new deal still carries itsstatus; the lost reason is filled in on the next update once the deal exists.
Field name must be snake_case
lost_reason. The SDK JSON-encodes the payload array verbatim, and every deal field the API accepts is snake_case (org_id,person_id, …). A camelCaselostReasonis silently ignored — the deal flips tostatus=lostbut no reason is stored. (This was a real bug the send-modequote-follow-up-status.spec.tscaught; the inboundredoPipedriveDatabackfill had the same camelCase slip.) Don't "tidy" it to camelCase.
When nothing is pushed (or something else happens)
- Quote deleted → if a matching deal exists it is deleted on Pipedrive; otherwise nothing.
- No resolvable branch (the quote's user has no/invalid branch) → the quote is skipped.
- Deal was deleted on Pipedrive's side → the stale
pipedrive_deal_idlink is cleared and nothing is pushed this run (a fresh deal is created next time). - Non-live environment guard → outside
live, an update that would touch a deal not in the dev/test organisation is refused, so dev/staging can't clobber a live deal that happens to share a quote serial. - Validation failure (bad org id, RSM/person resolution, API error) → that quote's update is
logged as failed and remains queued, retried up to 3 times (
pipedrive_update_attempts) then skipped until the quote changes again.
The Quote Follow-Up landing page
The no-login Quote Follow-Up landing page (bcquotelandingpage, full feature write-up in
quote-follow-up/landing-page.md) is another producer of deal updates. It lets a field agent
set a quote's status, project stage, expected completion date and comments from an
email link. Every submission (saveUpdate → ConfigboxModelBcquotelandingpage::saveAgentUpdate)
routes status + lost reason through the shared updateAgentFeedbackStatus() /
updateAgentFeedbackLostReason() methods, so it always schedules a deal update — even a
comment-only or stage-only edit, because updateAgentFeedbackStatus() is always called (with the
current status).
Columns it writes
| Column | Written by | Reaches Pipedrive? |
|---|---|---|
pipedrive_agent_feedback_status | updateAgentFeedbackStatus() | ✅ Agent Feedback Status custom field (the agent's read — incl. won) |
pipedrive_status | saveAgentUpdate() → updatePipedriveDealStatus() (forced for open/won/lost); applyInitialStage() → `updatePipedriveDealStatus(open | won, scheduleUpdate=false)at **quote creation**;applyDealChange()` from the My Quotes deal controls and the Quote Console detail (pipeline-stages.md) |
pipedrive_lost_reason | updatePipedriveDealStatus() (landing page: free text for Other; My Quotes / Quote Console: the Pipedrive lost-reason vocabulary, Other + text) | ✅ native Lost reason (update only — see the create-path note in the payload table) |
pipedrive_agent_feedback_lost_reason | updateAgentFeedbackLostReason() (set on lost; cleared on open/won) | ✅ Agent Feedback Lost Reason custom field |
pipedrive_stage_id | saveExtraFeedback() (direct SQL); applyInitialStage() at quote creation (creator's pick / default — pipeline-stages.md); applyDealChange() from the My Quotes deal controls / Quote Console | ✅ native stage_id (+ its pipeline_id) |
pipedrive_agent_feedback_completion_date | saveExtraFeedback() (direct SQL) | ✅ stock Expected close date (sent only when set) |
pipedrive_agent_feedback_comments | saveExtraFeedback() (direct SQL) | ✅ folded into the follow-up Activity (via the pending column below) |
pipedrive_agent_feedback_comment_pending | queueCommentNote() (set on a new/changed comment) → cleared after the Activity posts | ✅ the append-only comment queue (see below) |
pipedrive_agent_feedback_updated_on | touchFeedbackTimestamp() (direct SQL) | ❌ local only |
pipedrive_stage_changed_on | BcQuoteStageStamp::stampIfChanged() (only when the stage actually moves) | ❌ local only — see Deal-stage change timestamp |
Only
updateAgentFeedbackStatus()/updateAgentFeedbackLostReason()/updatePipedriveDealStatus()schedule the update;saveExtraFeedback(),queueCommentNote()andtouchFeedbackTimestamp()don't need to, since the status calls in the same submission already flagged the quote.
Field mapping (what actually lands on the deal)
| Landing-page input | Deal field | PD field key |
|---|---|---|
| Status = Open / Lost / won-via-stage | native status (open/lost/won) — a won-flagged stage sets won; a non-won stage sets open | — (native) |
| Status (Open / Won / Lost) | Agent Feedback Status (custom enum — the option whose label equals pipedrive_agent_feedback_status) | pipedrive_field_key_agent_feedback_status (CB settings) |
| Lost reason — for Other, the free-form text | native lost_reason (update only) | — (native) |
Lost reason — category ("Other" for Other) | Agent Feedback Lost Reason (custom) | pipedrive_field_key_agent_feedback_lost_reason (CB settings) |
| Project stage | native stage_id — the chosen stage (Pipedrive applies its win-probability) | pipedrive_stage_id (quote column) → sent for any imported stage, together with its pipeline_id |
| Expected completion date | stock Expected close date (expected_close_date, YYYY-MM-DD) | — (native) |
| Comments | folded into the follow-up activity (BcPipedriveApi::addActivity — one done activity per submission, type from pipedrive_activity_type_follow_up, carrying status/stage/date + comment) | — (not a field) |
Everything else on the payload (value, org, person, serial, deal type, alternatives, …) is rebuilt from the quote as usual — see the payload table.
How the comment avoids duplicates
The deal-field payload is idempotent, so it is safe to re-send on every run. The follow-up Activity
is not — it is append-only. So comments are not part of that payload: a new/changed comment is parked in
pipedrive_agent_feedback_comment_pending at submit time (BcQuotePipedriveGateway::queueCommentNote),
and updatePipedriveDeal() folds it into the one done Activity it logs after the deal exists
(BcPipedriveApi::addActivity), then clears the pending columns. Because they are cleared only on
success, a failed run re-queues the whole update and retries the activity without duplicating it.
Re-submitting the same comment text does not re-queue (the landing page compares against what's
already on file).
Gotchas specific to the landing page
- A won-flagged stage now wins the native deal (there is no "Won" button). Selecting the stage
flagged
is_won_stageforcesstatus = 'won', which setspipedrive_agent_feedback_status = 'won'(→ Agent Feedback Status custom field) and the nativepipedrive_status = 'won'(→ the deal's native Status), and moves the deal to that won stage viastage_id. A non-won stage forces the native status back toopen— so re-selecting a lower stage un-wins the deal.saveAgentUpdate()does this by callingupdatePipedriveDealStatus()directly, deliberately bypassing the sharedupdatePipedriveStatusConditionally()"suggest only" guard (which never propagatedwon). The My Quotes deal controls and the Quote Console write the native status the same way (applyDealChange(), 2026-08-21); the guard survives only on the retiring cold-quote path. - The stage moves the deal along the real pipeline.
pipedrive_stage_idis sent as the deal's nativestage_id, so Pipedrive moves the deal to that stage and applies the stage's win-probability to the weighted value. It is sent when the stored stage is a known imported stage (resolved via the company-scopedgetStageById()— guarding against a stale/foreign id), together with that stage's ownpipeline_id, so a deal follows even a cross-pipeline move. - Expected close date only propagates when set. Clearing the completion date on the quote (e.g. the lost path clears it) is not pushed as a clear, to avoid an empty-value rejection on create.
- A pipeline must be configured and stages imported.
pipedrive_pipeline_idselects the pipeline and the stages are imported into#__configbox_external_pipedrive_stages(backend Import stages from Pipedrive button orcb_pipedrive_import_stages.php). Blank/no import ⇒ the stage is silently not pushed (status, date and the note are unaffected).
Deal-stage change timestamp
The quote carries pipedrive_stage_changed_on — the UTC datetime its native Pipedrive deal stage
(pipedrive_stage_id) last actually moved, regardless of what drove the move. It is stamped by
BcQuoteStageStamp::stampIfChanged($quoteId, $newStageId) (system_overrides/BcQuoteStageStamp.php),
which every stage-writing path calls just before it writes the new stage:
- Quote Follow-Up landing page —
saveExtraFeedback()(models/bcquotelandingpage.php), so an agent picking a stage (or clearing it on a lost quote) stamps it. - Inbound Pipedrive webhook —
processDealDataChanges()(models/bcpipedrive.php), only when the webhook actually carried a stage change (the stage column is present in$storeByColumn).
The helper reads the currently-stored stage, compares it (NULL-safe: gaining or losing a stage counts)
and stamps gmdate('Y-m-d H:i:s') only on a real change — so the landing page re-saving the same
stage on every submit is a no-op. It never throws (an audit stamp must not break the flow it observes),
mirroring BcQuoteStageSyncLog and BcPipedriveChangeLog. The outbound push and
redoPipedriveData() never mutate the stored stage, so a full re-sync leaves this timestamp intact.
The one thing it can't catch is a stage edited straight in the database, bypassing the app. Any new
stage writer only needs to add the same one-line call. Column added by
updates/0.5.72.php (DATETIME NULL).
Distinct from the append-only stage-change log (quote-follow-up/stage-sync.md), which records a row per movement (with actor, source, previous stage, …). This is just the single "stage last moved at" stamp living on the quote itself.
The reverse direction
Pipedrive → website changes (a deal edited inside Pipedrive flowing back to the quote) are handled by
the inbound webhook, not by anything above. The webhook is queued: the endpoint only stores
the raw payload, and a cron runner (cb_pipedrive_process_webhook_queue.php) applies it out-of-band so
a Pipedrive bulk action can't overwhelm the app. See the "Inbound webhook queue" flow in
README.md.
Parity with the outbound push. The inbound processor reads back the same editable, quote-owned
fields the outbound push writes — they are driven by one shared map,
BcPipedriveConfig::webhookFieldMap(), so the two directions can't drift:
| Deal field (Pipedrive) | Quote column written back | Notes |
|---|---|---|
| native Status | pipedrive_status | non-lost clears pipedrive_lost_reason |
| native Lost reason | pipedrive_lost_reason | |
| Agent Feedback Status (custom enum) | pipedrive_agent_feedback_status | option id → label; non-lost clears the feedback lost-reason |
| Agent Feedback Lost Reason (custom) | pipedrive_agent_feedback_lost_reason | |
native stage_id | pipedrive_stage_id | stored as the raw numeric id; any imported stage (any pipeline) is accepted, an unknown one is skipped (not stored); stamps pipedrive_stage_changed_on |
| (derived from the stage) | pipedrive_pipeline_id | set from the accepted stage's own pipeline — the payload's raw pipeline_id field is deliberately ignored |
| stock Expected close date | pipedrive_agent_feedback_completion_date | empty → NULL |
The quote serial is not in that list, and cannot be. Website Quote Number is a plain, editable
varcharon the deal (Pipedrive offers no read-only flag, and it is bulk-editable), but the serial is minted here and travels outbound only. An inbound change to it is detected, refused, written to the change log asquote serial (rejected), counted asInbound-Serial-Overwrite-Rejected, and the true value is scheduled straight back to the deal. It used to be written to the quote — which renamed the quote to whatever was typed, orphaning every outstanding follow-up link, since the landing page and Link API resolve a quote by serial.
The last two (stage_id, Expected close date) closed a gap where those fields synced out but not
back in. Fields the outbound push derives from the quote (value, org, person, title, deal type,
"contains alternatives", OOT) are intentionally not read back — they have no quote column to own
them and writing them back would clobber quote-derived data.
Webhooks v2 only. The processor targets Pipedrive Webhooks v2. A v2 payload is flattened first
(normalizeWebhookPayload): entity from meta.entity, current state from data, and — importantly —
custom fields are un-nested from data.custom_fields.<key> (v2 sends {id:…} for option fields and
{value:…} for text/date) back to top-level <key> => scalar. Since the quote serial, Agent
Feedback Status, and Project Phase are all custom fields, a raw v2 payload would otherwise match no
quote at all — this flattening is what makes it work. A field counts as changed when it appears in
previous (v2 sends only the changed fields there), so there are no spurious writes. (A v1 payload,
having no meta.entity/data, simply doesn't match and is ignored — configure the Pipedrive webhook as
v2.)
Every applied change is written to the always-on change-log table
(#__configbox_external_pipedrive_change_log, direction = 'in'); the outbound push writes the mirror
rows (direction = 'out'). See README.md.
Where a NEW deal starts
A first push sends the configured default pipeline and stage (Custom Settings → Default pipeline / Default stage), not whatever the Pipedrive account defaults to.
Only on create. An update never sends them: a rep who moved a deal did so deliberately, and the quote having no local stage means "we have not been told otherwise", not "put it back at the start". The stage is guarded the same way as the quote-stage path — only a known, imported stage is ever sent, and a configured default that no longer resolves is logged and skipped rather than pushed.
Fixed 2026-08-21. Before that the export only sent a stage when the quote already had one, which a new quote never does, so Pipedrive filed every new deal under the account's own default pipeline. On an account whose default happened to be the right pipeline it looked correct, which is why it survived — it only shows up on an account configured any other way, and then every quote lands in a pipeline the follow-up flow knows nothing about.