Skip to main content

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 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
PieceFileRole
Resolver + guardsystem_overrides/BcPipedriveAuth.phpmode, credential, the company check
Value objectsystem_overrides/BcPipedriveCredential.phpauth header, baseV1/baseV2, company
HTTP clientsystem_overrides/BcPipedriveHttpClient.phpv1 + v2, 429 retry, header capture
OAuth flowsystem_overrides/BcPipedriveOauth.phpauthorize, exchange, refresh, storage, scopes
Endpointscontrollers/bcpipedriveoauth.phpinstall, callback, and the uninstall DELETE
Failure typesystem_overrides/BcPipedriveAuthException.phpthrown 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_DEV is now the per-environment setting pipedrive_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.

SettingDefaultNotes
pipedrive_auth_modetokentoken or oauth. The rollback lever
pipedrive_company_idproduction's idThe ONLY account this environment may reach. Change it in the same breath as the credential
pipedrive_org_id_dev6034 where the company is productionNon-live writes are confined to this organisation. Blank = unrestricted
pipedrive_api_tokenThe personal API token, used in token mode
pipedrive_webhook_urllive's URL on live, blank elsewhereWhere Pipedrive posts deal changes. Blank makes setup refuse rather than guess
pipedrive_oauth_client_idFrom the app's OAuth page in the Developer Hub
pipedrive_oauth_client_secretSame page. Server-to-server only
pipedrive_oauth_redirect_urilive's URL on the live row, blank elsewhereMust 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 BcPassword property, 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

URLWhat it does
/cb-api/bcpipedriveoauth/installMints a signed state, redirects to Pipedrive's consent screen. Use as the app's Installation URL
/cb-api/bcpipedriveoauth/callbackThe registered callback URL. Trades the code for tokens and stores them
DELETE on the same callback URLPipedrive'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_client are 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]
  1. Forces a refresh past 45 days and alarms past 55 — which is what saves an environment that runs no cron of its own.
  2. 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.
  3. Compares granted scopes against BcPipedriveOauth::requiredScopes.

It exits 1 when something needs a human, so cron mail surfaces it.

Scopes

ScopeNeeded for
baseanything at all
deals:fulldeals, and pipelines/stages reads
deal-fields:fullcreating the custom deal fields during setup
contacts:fullpersons and organizations
activities:fullthe Quote Follow-Up activity
users:readthe users import, and /users/me
search:readfinding a deal by quote serial
webhooks:fullregistering 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.

  1. 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.
  2. Pipedrive caps admin by the installing user's permission set. A non-admin may install an app that requests admin; 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 the is_admin flag stored from /users/me at 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:read is the dangerous one to forget. Without it searchDeals() 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.

  1. Sandbox → Settings → Developer Hub → Create an app → Create private app. Private vs public is permanent.
  2. 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.
  3. OAuth & access scopes: tick the eight above, copy the client id and secret into the settings.
  4. Install & test installs the draft into the sandbox and exercises the real flow. This button jumps straight to the callback with no state of ours, which is why state is verified only when one is present.
  5. 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:

AppCallback URLInstalled into
livehttps://betacalco.com/cb-api/bcpipedriveoauth/callbackthe production company
devhttps://pipedrive-app.dev.betacalco.com/cb-api/bcpipedriveoauth/callbackthe 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.com is 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 in token mode; 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.

SymptomCause
"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 exchangeClient id/secret wrong, or from a different app than issued the code
invalid_grantThe 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 Pipedrivepipedrive_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

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 /webhooks reports http_auth_user: null for 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.