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 bumpVERSIONand 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 v4in 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.
- 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.
- 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:
| Field | Type | Options | Priority | Why |
|---|---|---|---|---|
| Website Quote Number | varchar | — | required | The link between a quote and its deal, and what the deal search matches on. Without it every push creates a second deal. |
| Deal Type | enum | Reg. / Int. / Test | required | Separates real deals from internal and test ones. |
| Deal contains alternatives | enum | Yes / No | required | Flags a quote carrying alternative configurations. |
| Agent Feedback Quote Status | enum | open / won / lost | recommended | The agent's verdict, both ways. Matched by label, so those three must read exactly that. |
| Agent Feedback Lost Reason | text | — | recommended | The free-text reason behind a "lost". |
| Is OOT | enum | Yes / No | optional | Marks 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 Pricing | 10% |
| Quoted | 25% |
| Specified | 50% |
| Bidding | 50% |
| Won at Bid | 80% |
| Hold For Release | 95% |
| 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_nris 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:
| Setting | Written by | When |
|---|---|---|
pipedrive_field_key_* / pipedrive_field_name_is_oot | each field step | Immediately 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 fields | After 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_id | Default pipeline & stage | Only when the account leaves no room for doubt. |
pipedrive_activity_type_follow_up | Detect the follow-up activity type | When 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.
| Step | What it does |
|---|---|
| Check the record owner | Resolves the one Pipedrive user everything will belong to — derived from the branches table, not configured (see below) |
| An organisation for each branch | Creates the missing ones, and writes the new id onto the branch |
| Stamp the rep codes onto the organisations | Puts Rep Code and Consolidated Quota Code on every branch's organisation |
| Write the organisation ids back to the rep sheet | Otherwise the next rep import overwrites them with production's ids |
| A person for each active website user | Linked 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:
| Row | Automatic? |
|---|---|
| Authentication mode | Button — but only once the rows below are green. It re-checks them itself rather than trusting the page that offered it |
| App credentials | No. Client id and secret exist only in the Developer Hub |
| Callback URL | Button stores this host's callback in the settings. Registering the same string in the Developer Hub has no API and stays manual |
| App installed | No. An OAuth install is a person at a consent screen by definition |
| Installed by an admin | No. Without admin rights, provisioning fails part-way with a 403 that looks like a scope problem |
| Granted scopes | No. Scopes are fixed at install time, so adding them in the Hub means installing again |
| Token health | Reports 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
tokenand 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 by | So a re-sync… | |
|---|---|---|
| Branch → organisation | the branch's organisation_id when it resolves in this account, else the organisation name | adopts what is there, creates only branches with neither |
| Website user → person | the 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
- Open the page. It reads the account (about two seconds) and shows the report.
- Look at the report before touching anything. It is the whole point of the page.
- Adjust the plan — the ticks default to "everything that is not already OK".
- Start provisioning. Steps tick over one at a time.
- 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 button — Create 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
| Path | What |
|---|---|
system_overrides/BcPipedriveManifest.php | The declaration — edit this when the integration changes |
system_overrides/BcPipedriveProvisioning.php | status() and the steps: read an account, compare, add what is absent |
system_overrides/BcPipedriveProvisioningRun.php | Run persistence, the step loop, the log |
controllers/adminbcpipedriveprovisioning.php | loadStatus / startRun / runStep / getRun / abortRun / downloadLog |
views/adminbcpipedriveprovisioning/ | The page shell (the module renders the rest) |
assets/javascript/adminbcpipedriveprovisioning.js | Status rendering, the plan, the run loop |
updates/0.5.99.php | #__configbox_external_pipedrive_provisioning_runs |
updates/0.5.100.php | manifest_version on runs — what makes drift detectable |
updates/0.5.101.php | touched_on on runs — what makes an abandoned run reclaimable |
system_overrides/BcPipedriveDataProvisioning.php | The data half — organisations, persons, ownership |
tests/specs/backend/pipedrive-provisioning.spec.ts | Coverage — 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 /webhooksreturnshttp_auth_user: nullfor 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.