From 60349785d85f28f89b216460e9c4eff889f0ab88 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 2 Jul 2026 11:24:05 -0400 Subject: [PATCH 1/3] feat: add secret_key config, deprecate personal_api_key The local feature flag / remote config credential now accepts either a Personal API Key or a Project Secret API Key, so the field is renamed to secret_key. personal_api_key is kept as a deprecated alias that resolves into secret_key, with a DeprecationWarning, so existing code keeps working. --- posthog/__init__.py | 11 +++++---- posthog/client.py | 45 +++++++++++++++++++++++++++---------- posthog/test/test_client.py | 22 ++++++++++++++++++ 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/posthog/__init__.py b/posthog/__init__.py index 871b3ceb..b20d9c2b 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -312,8 +312,9 @@ def get_tags() -> Dict[str, Any]: sync_mode: If True, send events synchronously instead of using background worker threads. disabled: If True, disable captures and API requests. Useful in tests. - personal_api_key: Personal API key used for local feature flag evaluation - and remote config payloads. + secret_key: A Personal API Key or Project Secret API Key used for local + feature flag evaluation and remote config payloads. + personal_api_key: Deprecated alias for secret_key. poll_interval: Seconds between local feature flag definition refreshes. disable_geoip: Whether to disable server-side GeoIP enrichment. Defaults to True. @@ -359,7 +360,8 @@ def get_tags() -> Dict[str, Any]: send = True # type: bool sync_mode = False # type: bool disabled = False # type: bool -personal_api_key = None # type: Optional[str] +secret_key = None # type: Optional[str] +personal_api_key = None # type: Optional[str] # Deprecated: use secret_key project_api_key = None # type: Optional[str] poll_interval = 30 # type: int disable_geoip = True # type: bool @@ -926,7 +928,7 @@ def get_remote_config_payload( The payload associated with the feature flag. If payload is encrypted, the return value will be decrypted Note: - Requires personal_api_key to be set for authentication + Requires secret_key to be set for authentication """ return _proxy( "get_remote_config_payload", @@ -1150,6 +1152,7 @@ def setup() -> Client: on_error=on_error, send=send, sync_mode=sync_mode, + secret_key=secret_key, personal_api_key=personal_api_key, poll_interval=poll_interval, disabled=disabled, diff --git a/posthog/client.py b/posthog/client.py index 1fbff223..49bdf783 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -234,6 +234,7 @@ def __init__( timeout=15, thread=1, poll_interval=30, + secret_key=None, personal_api_key=None, disabled=False, disable_geoip=True, @@ -285,8 +286,15 @@ def __init__( timeout: HTTP request timeout in seconds for event uploads. thread: Number of background consumer threads. poll_interval: Seconds between local feature flag definition refreshes. - personal_api_key: Personal API key used for local feature flag - evaluation and remote config payloads. + secret_key: A Personal API Key or Project Secret API Key, used to + authenticate local feature flag evaluation, remote config + payloads, and decrypted flag payloads. Example:: + + posthog.Client(project_api_key, secret_key="phx_...") + + personal_api_key: Deprecated alias for ``secret_key``. Still honored + for backwards compatibility; prefer ``secret_key``, which also + accepts a Project Secret API Key. disabled: If True, disable captures and API requests. Useful in tests. disable_geoip: Whether to disable server-side GeoIP enrichment. Defaults to True. @@ -440,12 +448,25 @@ def __init__( self.project_root = project_root - # personal_api_key: This should be a generated Personal API Key, private - self.personal_api_key = ( - personal_api_key.strip() - if isinstance(personal_api_key, str) - else personal_api_key + if personal_api_key is not None and secret_key is None: + warnings.warn( + "`personal_api_key` is deprecated; use `secret_key` instead. " + "`secret_key` accepts a Personal API Key or a Project Secret API Key.", + DeprecationWarning, + stacklevel=2, + ) + elif secret_key is not None and personal_api_key is not None: + self.log.warning( + "[FEATURE FLAGS] Both `secret_key` and `personal_api_key` were " + "provided; using `secret_key` and ignoring `personal_api_key`." + ) + resolved_secret_key = secret_key if secret_key is not None else personal_api_key + self.secret_key = ( + resolved_secret_key.strip() + if isinstance(resolved_secret_key, str) + else resolved_secret_key ) or None + self.personal_api_key = self.secret_key if debug: # Ensures that debug level messages are logged when debug mode is on. # Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided @@ -1816,7 +1837,7 @@ def _fetch_feature_flags_from_api(self): personal_api_key = self.personal_api_key if personal_api_key is None: self.log.warning( - "[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags." + "[FEATURE FLAGS] You have to specify a secret_key to use feature flags." ) return @@ -1871,7 +1892,7 @@ def _fetch_feature_flags_from_api(self): if e.status == 401: detail = ( f"Error loading feature flags: {e.message}. " - "Please verify both your project_api_key and personal_api_key. " + "Please verify both your project_api_key and secret_key. " "More information: https://posthog.com/docs/api/overview" ) self.log.error("[FEATURE FLAGS] %s", detail) @@ -1931,7 +1952,7 @@ def load_feature_flags(self): if not self.personal_api_key: self.log.warning( - "[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags." + "[FEATURE FLAGS] You have to specify a secret_key to use feature flags." ) self.feature_flags = [] return @@ -2618,7 +2639,7 @@ def get_remote_config_payload(self, key: str): returned. Note: - Requires ``personal_api_key`` for authentication. + Requires ``secret_key`` for authentication. Category: Feature flags @@ -2628,7 +2649,7 @@ def get_remote_config_payload(self, key: str): if self.personal_api_key is None: self.log.warning( - "[FEATURE FLAGS] You have to specify a personal_api_key to fetch decrypted feature flag payloads." + "[FEATURE FLAGS] You have to specify a secret_key to fetch decrypted feature flag payloads." ) return None diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 10a173fa..1e0c34b5 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -78,6 +78,28 @@ def test_trims_host_and_personal_api_key_whitespace(self): self.assertEqual(client.host, "https://eu.i.posthog.com") self.assertIsNone(client.personal_api_key) + def test_secret_key_sets_credential_and_mirrors_personal_api_key(self): + client = Client(FAKE_TEST_API_KEY, secret_key="phx_secret", send=False) + + self.assertEqual(client.secret_key, "phx_secret") + self.assertEqual(client.personal_api_key, "phx_secret") + + def test_personal_api_key_is_deprecated_alias_for_secret_key(self): + with self.assertWarns(DeprecationWarning): + client = Client(FAKE_TEST_API_KEY, personal_api_key="phx_legacy", send=False) + + self.assertEqual(client.secret_key, "phx_legacy") + + def test_secret_key_wins_when_both_provided(self): + client = Client( + FAKE_TEST_API_KEY, + secret_key="phx_secret", + personal_api_key="phx_legacy", + send=False, + ) + + self.assertEqual(client.secret_key, "phx_secret") + def test_client_with_empty_api_key_is_noop(self): client = Client("", send=False) From 4a4a39276006ed4c02f16244eb44713427a72fda Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 2 Jul 2026 12:10:01 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20green=20CI=20=E2=80=94=20update=20sn?= =?UTF-8?q?apshot,=20migrate=20assertion,=20parameterize=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Regenerate public API snapshot for the new secret_key field - Update the unauthorized-error test to assert secret_key - Collapse the three secret_key tests into one parameterized table - ruff format --- posthog/test/test_client.py | 45 ++++++++++++++++-------------- references/public_api_snapshot.txt | 6 ++-- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 1e0c34b5..a427fe3d 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -2,6 +2,7 @@ import asyncio import time import unittest +import warnings from datetime import datetime from uuid import UUID, uuid4 @@ -78,27 +79,29 @@ def test_trims_host_and_personal_api_key_whitespace(self): self.assertEqual(client.host, "https://eu.i.posthog.com") self.assertIsNone(client.personal_api_key) - def test_secret_key_sets_credential_and_mirrors_personal_api_key(self): - client = Client(FAKE_TEST_API_KEY, secret_key="phx_secret", send=False) - - self.assertEqual(client.secret_key, "phx_secret") - self.assertEqual(client.personal_api_key, "phx_secret") - - def test_personal_api_key_is_deprecated_alias_for_secret_key(self): - with self.assertWarns(DeprecationWarning): - client = Client(FAKE_TEST_API_KEY, personal_api_key="phx_legacy", send=False) - - self.assertEqual(client.secret_key, "phx_legacy") - - def test_secret_key_wins_when_both_provided(self): - client = Client( - FAKE_TEST_API_KEY, - secret_key="phx_secret", - personal_api_key="phx_legacy", - send=False, - ) + @parameterized.expand( + [ + ("secret_key_only", "phx_secret", None, "phx_secret", False), + ("personal_api_key_alias", None, "phx_legacy", "phx_legacy", True), + ("secret_key_wins", "phx_secret", "phx_legacy", "phx_secret", False), + ] + ) + def test_secret_key_resolution( + self, _, secret_key, personal_api_key, expected, expect_deprecation + ): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = Client( + FAKE_TEST_API_KEY, + secret_key=secret_key, + personal_api_key=personal_api_key, + send=False, + ) - self.assertEqual(client.secret_key, "phx_secret") + self.assertEqual(client.secret_key, expected) + self.assertEqual(client.personal_api_key, expected) + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + self.assertEqual(bool(deprecations), expect_deprecation) def test_client_with_empty_api_key_is_noop(self): client = Client("", send=False) @@ -832,7 +835,7 @@ def test_load_feature_flags_unauthorized(self, patch_get): self.assertEqual(client.cohorts, {}) self.assertIn("Unauthorized", logs.output[0]) self.assertIn("project_api_key", logs.output[0]) - self.assertIn("personal_api_key", logs.output[0]) + self.assertIn("secret_key", logs.output[0]) @mock.patch("posthog.client.flags") def test_dont_override_capture_with_local_flags(self, patch_flags): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 772cf18d..bbf301dd 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -505,13 +505,14 @@ attribute posthog.client.Client.is_server = is_server attribute posthog.client.Client.log = logging.getLogger('posthog') attribute posthog.client.Client.log_captured_exceptions = log_captured_exceptions attribute posthog.client.Client.on_error = on_error -attribute posthog.client.Client.personal_api_key = (personal_api_key.strip() if isinstance(personal_api_key, str) else personal_api_key) or None +attribute posthog.client.Client.personal_api_key = self.secret_key attribute posthog.client.Client.poll_interval = poll_interval attribute posthog.client.Client.poller: Optional[Poller] = None attribute posthog.client.Client.privacy_mode = privacy_mode attribute posthog.client.Client.project_root = project_root attribute posthog.client.Client.queue: Queue = Queue(max_queue_size) attribute posthog.client.Client.raw_host = normalize_host(host) +attribute posthog.client.Client.secret_key = (resolved_secret_key.strip() if isinstance(resolved_secret_key, str) else resolved_secret_key) or None attribute posthog.client.Client.send = send attribute posthog.client.Client.super_properties = super_properties attribute posthog.client.Client.sync_mode = sync_mode @@ -708,6 +709,7 @@ attribute posthog.request.RequestsTimeout = requests.exceptions.Timeout attribute posthog.request.SocketOptions = List[Tuple[int, int, Union[int, bytes]]] attribute posthog.request.USER_AGENT = 'posthog-python/' + VERSION attribute posthog.request.US_INGESTION_ENDPOINT = 'https://us.i.posthog.com' +attribute posthog.secret_key = None attribute posthog.send = True attribute posthog.super_properties = None attribute posthog.sync_mode = False @@ -826,7 +828,7 @@ class posthog.ai.types.ToolInProgress class posthog.args.OptionalCaptureArgs class posthog.args.OptionalSetArgs class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic) -class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False) +class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, secret_key=None, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, dedicated_ai_endpoint=False) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) From 573cf19cf941ec7c1339a23dbd483ae9322dd9ef Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 2 Jul 2026 12:39:11 -0400 Subject: [PATCH 3/3] test: assert secret_key in wrong-key error message Second assertion (in test_feature_flags) still pinned the old personal_api_key wording after the message was renamed to secret_key. --- posthog/test/test_feature_flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index d247a166..74e55520 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -2767,7 +2767,7 @@ def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll): client.load_feature_flags() self.assertIn("Unauthorized", logs.output[0]) self.assertIn("project_api_key", logs.output[0]) - self.assertIn("personal_api_key", logs.output[0]) + self.assertIn("secret_key", logs.output[0]) client.debug = True with self.assertRaisesRegex(APIError, "Unauthorized"): client.load_feature_flags()