The Pipedrive app, and how the site authenticates
Audience: developers & administrators · Scope: the credential layer — which credential an environment uses, which Pipedrive account it may reach, the OAuth endpoints, token lifecycle and the health checks around it · Last reviewed: 2026-08-07
TL;DR: Every Pipedrive call resolves its credential through BcPipedriveAuth, which answers
three things at once: which credential (pipedrive_auth_mode — the registered app, or a personal
API token), which host (an app installation's own api_domain), and which company — checked
against pipedrive_company_id and refused on a mismatch, on reads as well as writes. That last check
is what lets one codebase face both production and the developer sandbox without a dev box ever
writing to real deals.
Why a registered app at all
The integration used to authenticate with one 40-character personal API token belonging to the
ConfigBox API User, shared across every environment row. That is workable while the site talks to
exactly one Pipedrive company as one fixed user. It costs:
- No test environment. testing.md opens with "There is no Pipedrive sandbox — only production", which is why every write is funnelled through an org guard and a capture mode.
- A quarter of the rate budget. Burst limits are per user on a rolling 2-second window; an OAuth app gets roughly 4× the API-token allowance.
- Personal attribution, and a long-lived bearer secret with no rotation story.
- A clock on the sandbox. Pipedrive deletes a developer sandbox with no app created within 45 days, and none switched live within six months.
The credential layer
BcPipedriveApi (the chokepoint, 26 wrappers)
└── BcPipedriveAuth::resolve() which credential, which host, which company
├── oauth → BcPipedriveOauth::getAccessToken() refreshed, locked
├── token → BcPipedriveConfig::apiToken()
└── assertCompanyAllowed() ← refuses a mismatch before anything is sent
└── BcPipedriveHttpClient::forCredential() both API versions, 429-aware
| Piece | File | Role |
|---|---|---|
| Resolver + guard | system_overrides/BcPipedriveAuth.php | mode, credential, the company check |
| Value object | system_overrides/BcPipedriveCredential.php | auth header, baseV1/baseV2, company |
| HTTP client | system_overrides/BcPipedriveHttpClient.php | v1 + v2, 429 retry, header capture |
| OAuth flow | system_overrides/BcPipedriveOauth.php | authorize, exchange, refresh, storage, scopes |
| Endpoints | controllers/bcpipedriveoauth.php | install, callback, and the uninstall DELETE |
| Failure type | system_overrides/BcPipedriveAuthException.php | thrown when nothing usable resolves |
There is deliberately no automatic failover from app to token. It looks like resilience and is
the exact mechanism by which a dev box, whose sandbox installation just expired, starts
authenticating as production. If OAuth cannot produce a credential, that is an error. Rolling back is
a human flipping one setting — a settings change, not a deploy, the same lever shape
pipedrive_api_v2_groups gave the v2 migration.
The company guard
The single most safety-critical rule in the integration. Every resolved credential is compared to the company this environment declares, and a mismatch throws before a byte goes out:
Refused to talk to Pipedrive company 20731130 from the "dev" environment,
which is configured for company 1638657. Nothing was sent.
It logs to custom_pipedrive_auth and error, and queues the CloudWatch metric
Pipedrive-Auth-Company-Mismatch.
It runs on reads too. A read against the wrong account is not harmless: it is how the field registry, the pipeline import and the deal lookups end up describing an account the pushes will never reach, which then presents as a hundred unrelated bugs.
It also fails closed when unset — "we never said which account this is" is not a safe state to write from.
This is a strict superset of the older DEV-org guard, which only covered writes and only asked about the environment name. Both are kept: two independent guards against a cross-account write is the right number.
ORG_ID_DEVis now the per-environment settingpipedrive_org_id_dev, because production's DEV organisation (6034) does not exist in any other account. Blank means no organisation restriction, which is correct for a sandbox — there is no production data to protect there, and pinning every test deal to one organisation would make multi-org cases untestable.
Admin settings
All per environment, on #__configbox_external_settings_pipedrive, under Settings → Pipedrive
Integration.
| Setting | Default | Notes |
|---|---|---|
pipedrive_auth_mode | token | token or oauth. The rollback lever |
pipedrive_company_id | production's id | The ONLY account this environment may reach. Change it in the same breath as the credential |
pipedrive_org_id_dev | 6034 where the company is production | Non-live writes are confined to this organisation. Blank = unrestricted |
pipedrive_api_token | — | The personal API token, used in token mode |
pipedrive_webhook_url | live's URL on live, blank elsewhere | Where Pipedrive posts deal changes. Blank makes setup refuse rather than guess |
pipedrive_oauth_client_id | — | From the app's OAuth page in the Developer Hub |
pipedrive_oauth_client_secret | — | Same page. Server-to-server only |
pipedrive_oauth_redirect_uri | live's URL on the live row, blank elsewhere | Must match the Developer Hub character for character |
Secrets are masked in the settings form — the client secret, the API token and the webhook password all use the
BcPasswordproperty, which renders a masked input carrying every documented "leave this alone" hint (autocomplete="new-password",data-lpignore,data-1p-ignore,data-bwignore,data-form-type="other"). That stops autofill and the major password managers attaching themselves. It cannot fully suppress Chrome's "save password?" prompt — no attribute can — and the only certain cure,type="text"with-webkit-text-security, is rejected because Firefox does not support it and would show live API tokens in plain sight.
The redirect URI is blank on non-live rows on purpose. Inheriting production's would send a developer to the live app's consent screen, and Pipedrive would redirect the authorization code to betacalco.com. Blank reads as "not configured", which is honest.
Created by migrations 0.5.90 (OAuth credentials + token table), 0.5.93 (the credential layer) and
0.5.94 (installation lifecycle columns).
The endpoints
| URL | What it does |
|---|---|
/cb-api/bcpipedriveoauth/install | Mints a signed state, redirects to Pipedrive's consent screen. Use as the app's Installation URL |
/cb-api/bcpipedriveoauth/callback | The registered callback URL. Trades the code for tokens and stores them |
DELETE on the same callback URL | Pipedrive's uninstall notification — same URL, distinguished only by method |
Served by the generic /cb-api/<controller>/<task> endpoint,
a frontname the system plugin claims before menu matching. That matters: Pipedrive stores the
callback as a literal string and an app has only one, so a route that lives in each database
(like the inbound webhook's menu row) is exactly the wrong shape. /cb-api/… is code, so it resolves
on every host the code is deployed to.
The two browser endpoints are unauthenticated by design — whoever arrives has already authenticated
against Pipedrive, the authorization code is single-use, expires in ~5 minutes, and is worthless
without the client secret. The uninstall DELETE fails closed: HTTP Basic where the username is
the client id and the password the client secret, compared with hash_equals, plus a check that the
payload's client_id is this environment's app.
Token lifecycle
- Access tokens last an hour.
getAccessToken()refreshes 5 minutes before expiry. - Refresh tokens die after 60 days of NON-USE, and the clock resets on every use — so a refresh is the cure, not a workaround.
- The refresh takes a MySQL named lock and re-reads the row after taking it, so the loser of a race uses the winner's fresh token instead of burning a second exchange. If the lock cannot be taken and the stored token is stale-but-valid, it is used — which is what the 5-minute skew buys.
- Failures are classified:
invalid_grant/invalid_clientare terminal (status = needs_reauth; only a human at the consent screen fixes it); network and 5xx are transient. - Installations are never deleted on uninstall — the row is marked, tokens cleared. "Uninstalled
on
<date>by user<id>" is useful; an absent row is indistinguishable from "never installed".
Re-authorising is always the same: open /cb-api/bcpipedriveoauth/install and approve.
Health checks
The Connection panel on Diagnostics → Pipedrive Change Log reports the mode, the company, who installed the app, how long since the last refresh, and any problem in plain words — "the app was uninstalled in Pipedrive", "installed by a non-admin user, so it cannot create deal fields or manage webhooks", "not refreshed for 55 days; it expires after 60".
cli/cb_pipedrive_oauth_watchdog.php, daily, catches the three ways this stops working without
anything throwing at the time it breaks:
php docroot/cli/cb_pipedrive_oauth_watchdog.php [--dry-run=1]
- Forces a refresh past 45 days and alarms past 55 — which is what saves an environment that runs no cron of its own.
- Asserts a webhook subscription still exists for
pipedrive_webhook_url. Uninstalling removes every webhook the app created, and a human can delete one in the UI; either way inbound sync just goes quiet, so the silence has to be checked for. - Compares granted scopes against
BcPipedriveOauth::requiredScopes.
It exits 1 when something needs a human, so cron mail surfaces it.
Scopes
| Scope | Needed for |
|---|---|
base | anything at all |
deals:full | deals, and pipelines/stages reads |
deal-fields:full | creating the custom deal fields during setup |
contacts:full | persons and organizations |
activities:full | the Quote Follow-Up activity |
users:read | the users import, and /users/me |
search:read | finding a deal by quote serial |
webhooks:full | registering and removing the inbound webhook |
| admin | creating the pipeline and its stages (POST /pipelines, POST /stages) |
admin IS needed — but only by the provisioning page. Established 2026-08-20, the hard way:
everything the day-to-day sync does works without it, so an installation missing it looks perfectly
healthy right up to POST /pipelines, which answers 403 Scope and URL mismatch. Reading
pipelines and stages is covered by deals:full; writing them is account-settings work.
It lives in BcPipedriveOauth::provisioningScopes, not requiredScopes — the watchdog must not nag
an installation that syncs perfectly well, while the provisioning page must say so before you press
Start.
Granting it is only half the fix, twice over.
- Scopes are fixed at install time. Ticking the box in the Developer Hub does nothing to a token already issued — the app has to be re-installed.
- Pipedrive caps
adminby the installing user's permission set. A non-admin may install an app that requestsadmin; the marketplace allows it and the app simply never receives the rights. Nothing fails at install time — the first account-settings write 403s instead, which is the same late, silent failure shape as a missing scope.BcPipedriveOauth::adminCapabilityProblem()checks both, using theis_adminflag stored from/users/meat install. See Pipedrive's own note.
Pipedrive lists more under the admin scope than we have hit — activity types, custom fields for
deals/persons/organizations, user management, webhooks. We hold the dedicated deal-fields:full,
contact-fields:full and webhooks:full scopes and those have sufficed so far, but whether they
suffice on their own is unverified; the next full provisioning run against a blank account will
settle it.
The registered app deliberately grants MORE than this — Mail, Products, Leads, Recents, Projects and Project Fields are on as well, and nothing in the integration uses them. That is a considered decision, not an oversight: a private app is installed by us, into our own company, by an admin who already has all of this access, so least privilege buys little here — while a missing scope fails at call time with a 403, possibly weeks later in a nightly job. The table above is what must never be un-granted; the extras are headroom for whatever the integration grows into next.
Keep Deal Fields, Contact Fields and Product Fields granted regardless — the field registry reads all four field resources.
search:readis the dangerous one to forget. Without itsearchDeals()403s, the push cannot find the existing deal for a quote serial, and it takes the create path instead — duplicating a deal on every push, silently and cumulatively. A missing scope fails at call time, not install time, which is why the watchdog checks it.
Registering the app (Developer Hub)
Developer Hub exists only in a developer sandbox account, never a production one; it is the installation that targets a company.
- Sandbox → Settings → Developer Hub → Create an app → Create private app. Private vs public is permanent.
- Basic info: name, and the callback URL. A non-working URL is fine at this stage — creating the app is what stops the 45-day sandbox clock, and the field is editable later.
- OAuth & access scopes: tick the eight above, copy the client id and secret into the settings.
- Install & test installs the draft into the sandbox and exercises the real flow. This button
jumps straight to the callback with no
stateof ours, which is why state is verified only when one is present. - Change to live when the callback URL resolves. No review, no listing. This satisfies the six-month sandbox rule and cannot be undone.
Register two apps. An app has exactly one callback URL, so a shared app means every local test takes production's URL hostage:
| App | Callback URL | Installed into |
|---|---|---|
| live | https://betacalco.com/cb-api/bcpipedriveoauth/callback | the production company |
| dev | https://pipedrive-app.dev.betacalco.com/cb-api/bcpipedriveoauth/callback | the sandbox |
The dev app can stay in draft — "Install & test" works from draft, and one live app already satisfies the sandbox rule.
Install as the ConfigBox API User. OAuth acts as the installing user, so installing as the same
user the API token belongs to keeps attribution, visibility and permissions identical. This is not
cosmetic: addActivity() promises reps' completed-activity statistics stay unpolluted, and that holds
only because the API user is not a rep. A non-admin installer also silently loses field creation and
webhook management.
The callback URL is editable at any time, including after the app is live, effective on Save, with existing installations unaffected — for a private app. (A public Marketplace app needs re-approval.) Pipedrive validates it only at the Change to live click, so it has to resolve then. Two constraints stick: one URL per app, and both sides must change together, because the value is sent on the consent redirect and the token exchange.
Testing locally
Every worktree is published at https://<slug>.dev.betacalco.com the moment it is created — no flag,
no sudo, a real Let's Encrypt certificate (worktrees.md). Pipedrive
rejects self-signed certificates and *.ddev.site resolves to 127.0.0.1 everywhere, so that public
name is what makes local OAuth and webhook testing possible.
Pick the slug once and register it, so no Developer Hub edit is needed during development:
tools/worktree.sh create pipedrive-app
# → https://pipedrive-app.dev.betacalco.com/cb-api/bcpipedriveoauth/callback
Then point the dev settings row at the sandbox — pipedrive_company_id, the credential, and the
field keys, all three together — and see field-registry.md for re-reading the
per-account ids.
Staging cannot host any of this.
staging.betacalco.comis source-filtered at the hoster's firewall and Pipedrive's callers are blocked, so it can receive neither the callback nor webhooks. It can still call out, so it runs intokenmode; inbound sync is untestable there.
A published worktree is an internet-reachable copy of a production-seeded site. Read the security note in worktrees.md, and
_known-issues.md#37, before pointing anything at one.
Troubleshooting
Everything logs to custom_pipedrive_oauth; credential resolution to custom_pipedrive_auth.
| Symptom | Cause |
|---|---|
| "Refused to talk to Pipedrive company X…" | pipedrive_company_id and the credential disagree. Fix them together |
| "does not declare which Pipedrive company it may talk to" | pipedrive_company_id is unset — the guard fails closed |
| "the app is not installed here" | oauth mode with no active installation for this company |
invalid_client on the exchange | Client id/secret wrong, or from a different app than issued the code |
invalid_grant | The code was used already or expired (~5 min). Start at /install |
| "The security token on this request did not verify" | The state is older than 15 minutes or was altered |
| Redirect-URI mismatch at Pipedrive | pipedrive_oauth_redirect_uri differs from the Developer Hub value — compare character for character, trailing slash included |
| "endpoint group is set to API v1 … cannot authenticate as the Pipedrive app" | The vendored SDK cannot carry an OAuth token. Put the group on v2, or go back to token mode |
Related docs
- field-registry.md — what the credential decides the meaning of
- api-access.md —
tools/pd, which speaks either mode - deployment.md — the ordered go-live steps
- README.md — the integration's four flows
The app webhook credential
Subscriptions the app registers authenticate with a credential this site generates, not with the two editable settings.
BcPipedriveWebhookCredential makes a fresh user (cbx-<env>-<8 hex>) and a 32-byte random password
at registration, sends the plaintext to Pipedrive with the subscription, and stores only the username
and a SHA-256 of the password on this environment's settings row — in two columns declared as
bcinternalstate, a property type that
never renders, never takes a value from a request, and masks itself on output. After that moment the plaintext
exists in exactly one place: inside Pipedrive.
Why it is not a setting. A webhook's credentials cannot be changed after it is registered. An editable field therefore offers an admin exactly one outcome — break the match and 401 every delivery until somebody re-registers. There is no decision to make, so there is no field.
Why a plain SHA-256 and not bcrypt. The password is 32 random bytes; a slow KDF exists to make low-entropy human passwords expensive to guess, and there is nothing to guess here. Every delivery would pay the cost for no gain. One-wayness is the only property needed.
The ingress accepts either credential — the app one, or the legacy
pipedrive_webhook_auth_user/_password pair — so subscriptions the token-mode setup registered by
hand keep delivering, and those two settings can be retired on their own schedule rather than as part
of the OAuth cutover.
You cannot check what a subscription sends.
GET /webhooksreportshttp_auth_user: nullfor every subscription regardless, and the create response echoes it only as ciphertext (probed 2026-08-21). On live, where the ingress is fail-closed, a successful delivery is the proof.