Skip to main content

Pipedrive provisioning

Audience: developers & AI agents · Scope: the manifest-driven backend page that reports what a Pipedrive account is missing and adds it idempotently · Last reviewed: 2026-08-24

Part of Pipedrive. Backend: Components → ConfigBox → Diagnostics → Pipedrive Provisioning (ConfigBox backend management rights, like the other admin screens). Related: field-registry.md (the local tables this fills), oauth-app.md (the credential it uses). It supersedes the Sandbox Mirror — a separate page that copied field definitions between two accounts using a second API token; that page, its client and its token setting were removed in favour of this one.

One page that answers "is this Pipedrive account ready for the integration?", and then makes it so.

Operator-facing version: admin-guide/pipedrive/provisioning.md — the same screen without the internals, for whoever actually presses the buttons.

Why it exists

Pointing the site at a Pipedrive account was a scavenger hunt. Six custom deal fields, each with a 40-character key that had to be copied into a setting by hand; option ids that are different in every account; four local imports; a webhook subscription; an activity type that can only be created in Pipedrive's own UI. cli/cb_pipedrive_setup_integration.php did some of it, from a shell, with no way to see beforehand what was missing and no way to see afterwards what it had done.

Every new environment paid that cost again — and the whole "make the sandbox a real test target" effort is mostly this problem.

The manifest is the source of truth

system_overrides/BcPipedriveManifest.php declares what a Pipedrive account must contain. It is data, not logic: custom fields with their entity, type and options; the activity type; the webhook; the settings a human decides. Each entry carries what it is for, how badly the integration needs it, and the manifest version it first appeared in.

If you change the integration, change the manifest. Reading a new custom field? Add an entry. Needing another option on an enum? Add it to that entry's options. Then bump VERSION and add a changelog line. That is the whole contract.

Two things follow, and they are the reason it is worth being a manifest rather than code that knows:

  • Any account can be measured against it. The same declaration provisions live and the sandbox — by name, because field codes and option ids are per account.
  • Every environment notices when it is behind. Each run records the manifest version it ran against. The page then says "last provisioned against v2, the integration now declares v4" and names the entries in between, badging them new in v4 in the report. Rolling out a change stops being something to remember.

The manifest also checks itself (selfCheck()) on every status read — duplicate keys, an option setting mapped to a label the field does not declare, a since above the released version. A typo in data is the one failure mode this design introduces, so it is checked rather than trusted.

What it does

Two halves, and the order matters.

  1. A status report. What should be there, and what is there in fact, read live from the connected account. It changes nothing and is safe against production at any time.
  2. A run. A sequence of idempotent steps, executed one per HTTP request, with a checkmark appearing as each finishes.

The page shows the report first. Nothing is written until you press Start provisioning.

The rules it keeps

  • Idempotent. A step whose work is already done reports skipped. Running twice is safe and the second run does almost nothing.

  • Additive. Fields are matched by name and adopted if they already exist. Nothing is renamed, retyped or deleted, ever. There is no "reset" — that is what the Pipedrive UI is for.

  • A field of the wrong type is reported, not repaired. Retyping an enum in Pipedrive discards its options, and those option ids are stored in settings and written on every deal that already carries them.

  • Adding an option to an existing field is the ONLY change made to something that already exists — opt-in, and verified. The Pipedrive endpoint replaces a field's option list, so the existing options are re-sent with their ids; then the field is re-read and every pre-existing (id, label) pair is checked to be exactly as it was. Any deviation stops the run with the before and after lists. Nothing is renamed or removed either way, and the before/after goes into the log regardless, so a run can be reconstructed from its log alone.

    That check is deliberate. This step cannot be "believed to be correct" — a shifted option id would not throw anything, it would just make the integration start writing a value that means something else, silently, on every deal. So it is not trusted; it is verified.

  • Settings are written for this environment only. Field keys and option ids are per account. Writing them to every environment row is exactly how a sandbox key used to land in live's settings.

  • It never guesses a URL or an organisation. A blank webhook URL means no subscription is registered, not a subscription pointing somewhere plausible.

What it provisions

Custom deal fields

Declared in the manifest. Six carry settings — a priority that is not a preference but a statement of what breaks without them:

FieldTypeOptionsPriorityWhy
Website Quote NumbervarcharrequiredThe link between a quote and its deal, and what the deal search matches on. Without it every push creates a second deal.
Deal TypeenumReg. / Int. / TestrequiredSeparates real deals from internal and test ones.
Deal contains alternativesenumYes / NorequiredFlags a quote carrying alternative configurations.
Agent Feedback Quote Statusenumopen / won / lostrecommendedThe agent's verdict, both ways. Matched by label, so those three must read exactly that.
Agent Feedback Lost ReasontextrecommendedThe free-text reason behind a "lost".
Is OOTenumYes / NooptionalMarks a deal whose quote has an approved out-of-tolerance request.

"Required" is literal. Those three are written into every outbound deal payload unconditionally — a blank key puts an empty-string key into the payload array, so the push is malformed, not merely reduced. The other three are guarded by if (key !== '').

The other 46 fields (the "realism" set)

Live carries 52 custom fields; the integration reads six. The other 46 — 19 deal, 10 person, 17 organisation — are declared too, since manifest v3, and created by provisioning.

They are here because a sandbox that has only the six proves the six work and nothing about how the site behaves beside everything else, and it reads nothing like production to anyone looking at it. They were unaffordable until 2026-08-12: the sandbox allowed 30 custom fields in total and 6 + 46 is 52. Pipedrive raised it to live's allowance.

They carry no settings, deliberately — nothing in ConfigBox resolves them, so there is nothing to point anywhere; provisioning creates them and stops. Priority optional: not one can break the integration by being absent.

On the page they are one summary row ("Other fields the live account carries — 20 of 46 missing") with its own button and a batched step. Forty-six rows of "nothing reads this" would bury the six that can break a deal push.

To refresh the snapshot when live changes: import live's fields, then re-generate the table in BcPipedriveManifest::realismFieldEntries() and bump VERSION.

The pipeline and its stages

Declared in the manifest as a snapshot of the live account, taken 2026-08-12:

Probability
Pipeline Beta Calco's Pipeline
Budget Pricing10%
Quoted25%
Specified50%
Bidding50%
Won at Bid80%
Hold For Release95%
PO received (the won stage)100%

A snapshot rather than "read live's registry at runtime", for two reasons. The pipelines, stages and users tables carry no company_id (known issue #40), so the moment an environment is pointed at a second account the first account's rows are overwritten and its shape is gone — there is nothing left to read. And a declaration that changes under you is not a declaration.

Matched by name, created in manifest order. Two things it will not do:

  • order_nr is not sent. API v2 rejects it on create ("Parameter 'order_nr' is not allowed for this request") and assigns its own; creating in manifest order gets the sequence right anyway.
  • A stage that exists with a different probability is reported, not corrected. That number feeds Pipedrive's forecasting on every deal already sitting in the stage.

is_won_stage is ConfigBox's own flag on the local row — Pipedrive has no such property. The step sets it on the declared stage and clears it from every other stage of that pipeline, because issue #40 means a stage row can arrive carrying another account's flag.

Once the pipeline exists, the Default pipeline & stage step points the settings at it even if something else was already set — otherwise a fresh sandbox keeps Pipedrive's own default "Pipeline" and every website deal quietly lands in the wrong place.

Everything else

  • Local imports — fields & options, pipelines, stages, users, organisations (field-registry.md).
  • Option-id settings, resolved from the imported registry by matching option labels.

The settings it writes, and when

Provisioning does not just create things in Pipedrive — it wires this environment's ConfigBox settings to what it found or made, in two stages:

SettingWritten byWhen
pipedrive_field_key_* / pipedrive_field_name_is_ooteach field stepImmediately after find-or-create, in the same step. Only when it differs from what is stored, so a re-run reports skipped.
the 7 option-id settings (..._deal_type_option_*, ..._has_alternatives_option_*, ..._oot_option_*)Point the settings at the imported fieldsAfter the registry import — option ids come from the local registry, not the API, so they cannot be known before the field's options are imported. Matched by label.
pipedrive_default_pipeline_id, pipedrive_default_stage_idDefault pipeline & stageOnly when the account leaves no room for doubt.
pipedrive_activity_type_follow_upDetect the follow-up activity typeWhen the type exists and the setting does not already name it.

Two things worth knowing.

The option-id step is planned whenever any field is selected, not only when the fields import is too — and when the registry has nothing for a field it says so and names the fix, rather than leaving those settings blank in silence.

Settings are written for the environment you are running the page on, never for all of them. So provisioning configures the environment and the account it points at. Pointing dev at the sandbox and provisioning writes dev's row with sandbox keys — correct, and the whole reason for the rule. Provisioning from a dev site does not configure live's settings row; run the page on live for that.

  • Default pipeline and stage — but only when the account leaves no room for doubt (one pipeline, and its first stage). Otherwise it says so and leaves the choice to you.
  • The follow-up activity type — detected and stored. It cannot be created: the activity-types API is read-only, so the type is made once by hand in Pipedrive → Settings → Activities.
  • The inbound webhook — a deal subscription pointing at this environment's ingress URL.

The data half — an organisation per branch, a person per user

Everything above provisions schema. The data half provisions records, and it is what makes a quote exportable at all: updatePipedriveDeal() resolves org_id from branches.organisation_id and throws when that organisation is not in the account.

StepWhat it does
Check the record ownerResolves the one Pipedrive user everything will belong to — derived from the branches table, not configured (see below)
An organisation for each branchCreates the missing ones, and writes the new id onto the branch
Stamp the rep codes onto the organisationsPuts Rep Code and Consolidated Quota Code on every branch's organisation
Write the organisation ids back to the rep sheetOtherwise the next rep import overwrites them with production's ids
A person for each active website userLinked to their branch's organisation

It refuses to run against the production account — the gate is the Pipedrive company id, not the environment name, and every step re-checks it rather than trusting the card that hid the button. Generated organisations in the live account would be indistinguishable from real customer records.

Seeing and removing webhook subscriptions

A subscription is an object in Pipedrive holding one fixed URL. It fires at that host until somebody deletes it — configuring another site never moves it, it only adds a second receiver, and then two databases apply the same events and disagree. The Inbound webhook section therefore lists every subscription this environment's credential can see, not only its own:

  • rows for the subscriptions pointing at this site (create button when missing);
  • a row per subscription pointing anywhere else, headed Also delivering: <url>, each with a button that deletes it. That is how you keep one dev site receiving at a time.

The listing is scoped to the credential that created the subscription, and this catches people out: an app sees only what the app created, a personal token only what a token created. Proven on the sandbox — same company, same admin user, two credentials, two disjoint sets of three. Pipedrive's own webhook page shows only hand-made ones, so absence there is not evidence either. The section says so in a row of its own rather than leaving it to be discovered, but the practical rule is: to rule out a stray subscription, check with each credential, not just the one this environment holds.

Deleting follows the same rule — only the credential that created a subscription can remove it. The step refuses an id it cannot see and says why, rather than passing a doomed call to the API.

Getting onto the private app

The page carries an Authentication section that answers two questions — is this environment on the app rather than a personal token, and is the app actually set up? — and then walks the gap. The rows are in the order the obstacles arrive, and each one either has a button or tells you what to do:

RowAutomatic?
Authentication modeButton — but only once the rows below are green. It re-checks them itself rather than trusting the page that offered it
App credentialsNo. Client id and secret exist only in the Developer Hub
Callback URLButton stores this host's callback in the settings. Registering the same string in the Developer Hub has no API and stays manual
App installedNo. An OAuth install is a person at a consent screen by definition
Installed by an adminNo. Without admin rights, provisioning fails part-way with a 403 that looks like a scope problem
Granted scopesNo. Scopes are fixed at install time, so adding them in the Hub means installing again
Token healthReports only — refresh failures, expiry

Two things worth knowing while working down it:

  • The callback URL is derived from the request, never in CLI. A CLI run inside a worktree mints absolute URLs pointing at the main dev site (_known-issues.md #38), so the row declines to guess rather than storing a URL that would send the consent redirect somewhere else.
  • The API token is deliberately left in place by the switch. It is the rollback: one setting back to token and the environment works again, with no deploy and nothing to re-enter.

Which company counts as production is read from the live settings row, not hardcoded — that row is where the answer is declared, and it changes if the business ever moves accounts. The consequence is easy to miss: the live row is load-bearing for every other environment. Point it somewhere else — say, while "neutralising" a dev copy by rewriting all three rows at once — and the page cheerfully reports whatever it now names as the PRODUCTION account, so a sandbox is labelled production and the real warning never fires. When aiming an environment at a test account, change only that environment's row and leave live declaring the truth.

The owner is derived, not configured. "Everything belongs to one user" is already expressed by every branch carrying the same rsm_email, so the branches table is the declaration; more than one distinct RSM means the intent is not one owner, and the step says so instead of guessing.

The two codes on an organisation

An organisation Pipedrive holds carries two fields that say which rep it is: Rep Code (branches.code) and Consolidated Quota Code (branches.consolidated_quota_code, added by migration 0.5.103 and imported from the rep sheet's own column). Both are in the manifest's realism set, so provisioning creates the fields before anything tries to write them.

They are stamped twice over, and it needs both:

  • at creation, in the organisations step, so a new organisation is never briefly unlabelled;
  • in a step of its own, because the organisations step skips every branch that is already linked — which is all of them on a re-sync, and all of them for organisations that were adopted by name or created before the account had the fields.

The codes step fills blanks only. A value already in Pipedrive is reported and left alone, never overwritten — the same add-only rule, and these two are exactly the fields somebody edits by hand. The count of disagreements is the useful output: it is a data-quality report on the account. Two branches sharing one organisation will always disagree on one of them, which is itself worth seeing.

The field key is resolved per account from the local registry by name (ConfigboxModelAdminbcpipedrivefields::getFieldKeyByName()) — the manifest declares fields by name because a name is stable across accounts and a key is not. No registry entry means no write, and the step says so rather than failing.

Re-syncing: it adds, it never changes

Filling an account is a one-off; branches and website users keep being added. So the data steps are built to be run again, and the card carries its own Sync new records button that runs these four steps and nothing else.

What "new" means is a natural key on each side, not a local record of what was created:

Matched bySo a re-sync…
Branch → organisationthe branch's organisation_id when it resolves in this account, else the organisation nameadopts what is there, creates only branches with neither
Website user → personthe e-mail address, against every address on every person (a person can hold several)creates only users with no person carrying their address

Anything already there is left alone — no updates, no renames, no re-owning. That is deliberate: changing existing records would have to be totally error-free to be worth it, and adding is not.

There is no bulk-create endpoint for persons (checked against Pipedrive's API reference, 2026-08-20) — every person is its own POST, and the only bulk operation on persons is v1's delete-by-ids. So the batch size is the only lever: 100 per call, measured at 26–27 s, which halves the round trips against the old 50 while staying inside the 45-second proxy budget. Beyond that the next real speedup is concurrent requests, not a bigger batch.

A re-sync with nothing new costs about five API calls, because "who is missing?" is answered by one listing of the account rather than one search per user. It used to be a search each — ~1,740 calls to discover that nothing had changed, which is not a thing anyone runs routinely. The listing is capped; a result that fills the cap is treated as truncated and the step falls back to per-user verification, because creating from the gaps in a truncated listing would duplicate everybody past it.

The card shows what there is to mirror (branches, how many carry an organisation id, active website users) and when a data run last finished — all local reads, so it costs nothing on page load. What is actually missing in Pipedrive is only knowable by asking Pipedrive, which is what the run does.

The user count is the population, not the currently-creatable subset. The persons step needs a branch that already has an organisation, because a person has to be linked to one; the card is reporting the size of the job. The two diverge exactly when the job has not started — on an account with no organisations yet the step's set is empty, and the card used to read "0 active website users to mirror" for 1,739 of them. A summary that says "nothing to do" precisely when everything is to do is worse than no summary.

When the persons step does find nothing, it says which of the two it is: "1,739 website users are waiting on an organisation" rather than "there is nobody to create".

The custom-field allowance (no longer a limit)

A Pipedrive developer sandbox allows 30 custom fields in total, across deals, persons, organisations and products together — one pool, undocumented, established by hitting it. A production account allows far more.

So the plan is a checklist, not a button:

  • every field carries its priority, and how many slots it would cost (+1, or if it already exists);
  • the page shows how many custom fields the account already has, broken down by entity;
  • the allowance is an input (default 30), because the API does not report it — raise it when you are provisioning production;
  • the verdict under it says what the selection would cost and whether it fits.

Unticking a required field is allowed — provisioning in stages is legitimate — but it is called out, because the integration does not work without it.

The cap does not currently bind on this integration: it needs six custom deal fields and no person, organisation or product fields at all. It used to bind on the Sandbox Mirror, which copied whole resource types; that page has been removed in favour of this one.

The run lifecycle

A run is a small state machine, and two of its states catch people out: a failed run leaves everything before the failing step done, and an abandoned run (closed tab) sits running until the 15-minute stale timer lets the next run reclaim it. Every path back into running is safe because the steps are idempotent — a repeat reports skipped.

Running it

  1. Open the page. It reads the account (about two seconds) and shows the report.
  2. Look at the report before touching anything. It is the whole point of the page.
  3. Adjust the plan — the ticks default to "everything that is not already OK".
  4. Start provisioning. Steps tick over one at a time.
  5. When it finishes, the report above re-reads itself, so the page shows the account as it now is.

A run cannot be started while another is in flight for the same environment — two runs against one Pipedrive account is the one way this tool could create duplicate fields.

Reloading mid-run is safe. The page rejoins the run rather than starting a second one.

Stop after this step ends a run early. Nothing already done is undone; provisioning is idempotent, so the way back is to run it again.

The page

The connection strip names the account in words, not just an id — Beta-Calco - Sandbox #20731130 · beta-calco-sandbox.pipedrive.com, linked, so you can click through and recognise where you are. A non-live environment pointed at production turns the strip red.

Every row that is not right carries its own buttonCreate for a field, pipeline or stage, Import for a registry, Register for the webhook. It runs a real run (same planner, record, log and lock), then replaces itself with the outcome in place: no page blank, no scroll jump. Rows nothing can fix carry instructions instead (see the activity type) or a link to Custom Settings.

Data provisioning is its own card, always visible. When it cannot run it lists why — "Not possible yet: the custom deal fields are not all in place yet" — and turns itself on when the rows above go green. When it can, it states what there is to mirror and when it last ran, and carries Sync new records: the four data steps on their own, adding what has appeared since. Two ways in, because they answer different questions — the tick joins the full plan for a first fill, the button is the top-up.

Earlier runs on this environment lists the last dozen runs with outcome, step count, manifest version, who started it, and a log download per row. Each id opens that run via ?runId=.

When something fails

The run stops at the failing step, and that step shows its message and the first frames of its stack. Everything before it is done and stays done. Fix the cause and start a new run — the steps that already succeeded report themselves as skipped.

Download log serves a plain-text file: the environment, the Pipedrive company, every step with its outcome and its changes, the trace, and the timestamped log. That is the thing to attach to a message; it stands on its own without the page.

?runId=<id> on the page URL opens one particular run — the link to send when a run went wrong.

The log trail

Two records, deliberately, because they serve different people.

custom_pipedrive_provisioning.log (in ConfigBox's log directory) is for whoever is not at the screen — the person reading the server six weeks later. Every line is prefixed with the run, so grep 'run 26' custom_pipedrive_provisioning.log reconstructs one run from the file alone:

[run 26] Started for environment "dev", Pipedrive company 1638657, manifest v1, by user 812 (…), 13 steps: connection, field:quote_serial, …
[run 26] [skipped] Deal field "Deal Type" (0.28s) — Already in place — field key d81a3a92…
[run 26] [done] Import fields & options (1.68s) — 0 new, 191 updated, 0 deactivated, 0 new options.
[run 26] changed: Import fields & options: 0 new, 191 updated, …
[run 26] Setting "pipedrive_deal_type_option_regular" on environment "dev": (empty) -> "159".
[run 26] [done] Point the settings at the imported fields (0.04s) — 2 option settings written.
[run 26] Run finished — 4 done, 8 skipped, 1 warning. Started 17:15:54 UTC, finished 17:16:00 UTC.

It records every step with its outcome and duration, every change, every settings write as old -> new, run start with who started it, the finish summary, aborts, reclaimed stale runs, refused starts, log downloads, and unauthorized calls.

Secrets never reach it. A settings column whose name contains password, secret or token is logged as (set, 32 characters — value withheld). Provisioning generates the webhook password, so this is not hypothetical.

The downloadable run log is for whoever is at the screen, and for sending on: the same content plus the step list, the stack frames of a failure, and a header naming the environment, company, manifest version and site.

When a run is abandoned

A run only advances while a browser asks it to, so closing the tab mid-run leaves it running. Since only one run may be in flight per environment, that would block provisioning there for ever.

A run nobody has touched for 15 minutes is stale: the page says so, and starting a new run reclaims it — marked aborted, logged, nothing undone. The steps are idempotent, so the new run simply repeats what the old one did as skipped.

Two guards worth knowing about

Pointed at production from a non-live site. The connection strip turns red and says so. A dev site aimed at the production account reports identically to one aimed at the sandbox — same rows, same buttons — so the difference has to be impossible to skim past.

Webhook credentials are generated, but only when it is safe. If both are blank and no subscription exists, provisioning generates a strong pair and registers the subscription authenticated from birth. If a subscription already exists it will not touch them: changing the credentials under a live subscription makes Pipedrive keep sending the old ones, and inbound sync 401s until somebody re-registers. That is a change to something that exists, so it is reported instead.

Why one step per request

Provisioning is a dozen or so API calls plus five imports. Run in a single request it would sit for minutes, and every layer between the browser and PHP has an opinion about that — nginx, the Caddy in front of it, and whatever proxy the operator is behind, most of them at 60 seconds or less.

So startRun writes the plan and returns; each runStep executes exactly one step and returns the whole run. No response is ever long enough to be cut off, the browser can show progress that is real rather than animated, and a step is the unit of recovery.

Keep steps small. The slowest today is the organisations import (~2 s on an account with 1,600 organisations). Do not fold two imports into one step to save a round trip.

A MySQL advisory lock serialises the steps of one run, so a reloaded page whose predecessor is still polling cannot execute the same step twice. The steps are idempotent, so the account would survive that — the log would not, and a duplicated step reads like a bug.

Where it lives

PathWhat
system_overrides/BcPipedriveManifest.phpThe declaration — edit this when the integration changes
system_overrides/BcPipedriveProvisioning.phpstatus() and the steps: read an account, compare, add what is absent
system_overrides/BcPipedriveProvisioningRun.phpRun persistence, the step loop, the log
controllers/adminbcpipedriveprovisioning.phploadStatus / startRun / runStep / getRun / abortRun / downloadLog
views/adminbcpipedriveprovisioning/The page shell (the module renders the rest)
assets/javascript/adminbcpipedriveprovisioning.jsStatus rendering, the plan, the run loop
updates/0.5.99.php#__configbox_external_pipedrive_provisioning_runs
updates/0.5.100.phpmanifest_version on runs — what makes drift detectable
updates/0.5.101.phptouched_on on runs — what makes an abandoned run reclaimable
system_overrides/BcPipedriveDataProvisioning.phpThe data half — organisations, persons, ownership
tests/specs/backend/pipedrive-provisioning.spec.tsCoverage — read its SAFETY note before adding to it

Runs are never pruned automatically. They are rare and small, and the record of who provisioned what against which Pipedrive company is worth more than the bytes.

Provisioning makes its own inbound noise

A full data run creates ~1,850 records, and since manifest v4 the account is subscribed to person and organisation changes — so Pipedrive fires a webhook for every one of them back at this site. A first provision from empty queued 4,390 events in the trial on 2026-08-21.

Harmless but worth expecting: they land in #__configbox_external_pipedrive_webhook_queue and the drainer clears them at ~1,000 per run in about a second each. On an environment with no drainer cron they simply accumulate. Run cli/cb_pipedrive_process_webhook_queue.php a few times after a big data run rather than wondering why the queue has thousands of rows in it.

Caveats

  • The status page makes ~6 API calls (four field lists, the webhook list, the activity types) and caches nothing. A status page that can be stale is worse than none, because it is believed.
  • It provisions the account the environment is pointed at. The connection strip at the top names the company id for exactly that reason — the report looks the same whichever account it read.
  • The webhook step generates its own credential. It registers all three subscriptions (deal, person, organisation) with one generated user and password, sends the plaintext to Pipedrive, and keeps only a SHA-256 hash — see oauth-app.md. Nothing to type, nothing to edit, and nothing on this side to leak. The two settings fields stay for subscriptions the old token-mode setup registered by hand; the ingress accepts either.
  • Pipedrive never discloses what a subscription sends. GET /webhooks returns http_auth_user: null for every subscription, authenticated or not, and the create response echoes it only as ciphertext (probed 2026-08-21 — the API reference does not say). So the page cannot verify a subscription's credential, and neither can you. On live, where the ingress is fail-closed, an HTTP 200 delivery is the proof it matches; on dev it proves nothing, because dev accepts unauthenticated calls by design.
  • An app-created subscription is type: "application". The app owns it, only the app can remove it, and it goes when the app is uninstalled. It is not necessarily listed where a person looks for webhooks in Pipedrive's own settings, so its absence there is not evidence it is missing — the subscription row reports the last delivery time and HTTP status, and that is the evidence.
  • Site: in the downloaded log is wrong inside a worktree (known issue #38): getUrlBase() returns the main dev site's host. The environment and company id in the same header are correct.