Skip to main content

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 says Batch 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-wide GET_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_export is 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 quoteaddPosition()
Edit a line itemeditPosition()
Copy a line itemcopyPosition()
Delete a line itemdeletePosition()
Change a line item's quantityupdatePositionQuantity()
Toggle a line item's "alternative" flageditPositionIsAltFlag()
(Re)price a positionsetPositionPricing()
Add/store a position via the Quote ConsolestorePosition() (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 fieldseditQuote()
Set / rename the project name (My Quotes)storeProjectName() (bcmyquotes)
Duplicate a quote or create a new revisioncopyQuote()
Delete a quotedeleteQuote()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 reasonupdateAgentFeedbackLostReason()
A Pipedrive status change is applied to the quoteupdatePipedriveDealStatus()
The out-of-territory (OOT) form is submittedsubmitOotForm() (bcootform)
Agent submits the Quote Follow-Up landing pagesaveUpdate() (bcquotelandingpage) → saveAgentUpdate()updateAgentFeedbackStatus() (see dedicated section)

Conditional triggers (only fire in specific cases)

ActionCondition
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 calls editQuote(), which schedules unconditionally. The extra pipedrive_*-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; BcPipedriveApi translates it before sending (and before test capture) when the deals group is on v2: custom fields nest under custom_fields.<key>, user_id becomes owner_id, creator_user_id is dropped (v2 derives it), updates go as PATCH /deals/{id} with the id on the URL. See api-v2-migration.md.

Deal fieldValue sourceOn create (addADeal)On update (updateADeal)
Titlequote name, or the serial if unnamed✅ set❌ never re-titled
Organization (org_id)the quote's branch organisation
Valuediscounted quote total
Currencyquote 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
Statuspipedrive_status, only when it is open/won/lost✅ (when set)✅ (when set)
Lost reasonpipedrive_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 datepipedrive_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.comInternal (160); else → Regular (159).
  • Deal contains alternativesYes (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 native status: agents suggest won/lost/open, Beta Calco staff decide the actual deal status. (For open/lost the two are equal anyway; only a suggested won differs — it fills this field without touching the native Status. It also mirrors the inbound webhook, which writes this field back into pipedrive_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_reason is allowed.
  • No deal → addADeal. The title is set; lost_reason is removed from the payload because Pipedrive's create endpoint rejects it. A new deal still carries its status; 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 camelCase lostReason is silently ignored — the deal flips to status=lost but no reason is stored. (This was a real bug the send-mode quote-follow-up-status.spec.ts caught; the inbound redoPipedriveData backfill 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_id link 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 (saveUpdateConfigboxModelBcquotelandingpage::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

ColumnWritten byReaches Pipedrive?
pipedrive_agent_feedback_statusupdateAgentFeedbackStatus()Agent Feedback Status custom field (the agent's read — incl. won)
pipedrive_statussaveAgentUpdate()updatePipedriveDealStatus() (forced for open/won/lost); applyInitialStage() → `updatePipedriveDealStatus(openwon, scheduleUpdate=false)at **quote creation**;applyDealChange()` from the My Quotes deal controls and the Quote Console detail (pipeline-stages.md)
pipedrive_lost_reasonupdatePipedriveDealStatus() (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_reasonupdateAgentFeedbackLostReason() (set on lost; cleared on open/won)Agent Feedback Lost Reason custom field
pipedrive_stage_idsaveExtraFeedback() (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_datesaveExtraFeedback() (direct SQL)✅ stock Expected close date (sent only when set)
pipedrive_agent_feedback_commentssaveExtraFeedback() (direct SQL)✅ folded into the follow-up Activity (via the pending column below)
pipedrive_agent_feedback_comment_pendingqueueCommentNote() (set on a new/changed comment) → cleared after the Activity posts✅ the append-only comment queue (see below)
pipedrive_agent_feedback_updated_ontouchFeedbackTimestamp() (direct SQL)❌ local only
pipedrive_stage_changed_onBcQuoteStageStamp::stampIfChanged() (only when the stage actually moves)❌ local only — see Deal-stage change timestamp

Only updateAgentFeedbackStatus() / updateAgentFeedbackLostReason() / updatePipedriveDealStatus() schedule the update; saveExtraFeedback(), queueCommentNote() and touchFeedbackTimestamp() 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 inputDeal fieldPD field key
Status = Open / Lost / won-via-stagenative 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 textnative 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 stagenative 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 datestock Expected close date (expected_close_date, YYYY-MM-DD)— (native)
Commentsfolded 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_stage forces status = 'won', which sets pipedrive_agent_feedback_status = 'won' (→ Agent Feedback Status custom field) and the native pipedrive_status = 'won' (→ the deal's native Status), and moves the deal to that won stage via stage_id. A non-won stage forces the native status back to open — so re-selecting a lower stage un-wins the deal. saveAgentUpdate() does this by calling updatePipedriveDealStatus() directly, deliberately bypassing the shared updatePipedriveStatusConditionally() "suggest only" guard (which never propagated won). 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_id is sent as the deal's native stage_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-scoped getStageById() — guarding against a stale/foreign id), together with that stage's own pipeline_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_id selects the pipeline and the stages are imported into #__configbox_external_pipedrive_stages (backend Import stages from Pipedrive button or cb_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 pagesaveExtraFeedback() (models/bcquotelandingpage.php), so an agent picking a stage (or clearing it on a lost quote) stamps it.
  • Inbound Pipedrive webhookprocessDealDataChanges() (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 backNotes
native Statuspipedrive_statusnon-lost clears pipedrive_lost_reason
native Lost reasonpipedrive_lost_reason
Agent Feedback Status (custom enum)pipedrive_agent_feedback_statusoption id → label; non-lost clears the feedback lost-reason
Agent Feedback Lost Reason (custom)pipedrive_agent_feedback_lost_reason
native stage_idpipedrive_stage_idstored 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_idset from the accepted stage's own pipeline — the payload's raw pipeline_id field is deliberately ignored
stock Expected close datepipedrive_agent_feedback_completion_dateempty → NULL

The quote serial is not in that list, and cannot be. Website Quote Number is a plain, editable varchar on 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 as quote serial (rejected), counted as Inbound-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.