From 851a0b71cd98a19f0a2d425cfad80365c32b96a1 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:14:06 -0400 Subject: [PATCH 1/8] feat: add one-key CustomerIO client Co-Authored-By: Claude Opus 5.5 --- README.md | 37 ++++++ customerio/__init__.py | 5 +- customerio/client.py | 203 +++++++++++++++++++++++++++++++ customerio/client_base.py | 22 +++- tests/test_client.py | 247 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 customerio/client.py create mode 100644 tests/test_client.py diff --git a/README.md b/README.md index 9c2bc80..12ab185 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,43 @@ cio.track(customer_id="5", name="purchased") cio.track(customer_id="5", name="purchased", data={"price": 23.45}) ``` +### Quick start + +Create a client with your secret key (`ak_…`). It sends tracking calls to the Track API and transactional messages to the App API, and picks the US or EU region from the key. + +```python +import os + +from customerio import Client + +cio = Client(api_key=os.environ["CIO_API_KEY"]) +cio.identify(id="user_123", email="a@example.com") +cio.track(customer_id="user_123", name="order_completed") +cio.send_email( + { + "to": "a@example.com", + "identifiers": {"id": "user_123"}, + "transactional_message_id": 5, + } +) +``` + +Pass `region` to override the region in the key. Legacy App API keys also work here; they default to `Regions.US` unless you pass `region`. + +`identify`, `track`, `track_anonymous`, `pageview` and the `send_*` methods are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. + +If the key can't do what you asked, the call raises a `KeyCapabilityError` (a subclass of `CustomerIOException`). A public key (`wk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, chained from the original error. + +```python +from customerio import KeyCapabilityError + +try: + cio.send_email(request) +except KeyCapabilityError as e: + # e.capability == "transactional", e.status_code == 401 + print(e) # This key can't send transactional messages (401): ... +``` + ### Instantiating customer.io object Create an instance of the client with your [Customer.io credentials](https://fly.customer.io/settings/api_credentials). diff --git a/customerio/__init__.py b/customerio/__init__.py index 2955934..9c8b397 100644 --- a/customerio/__init__.py +++ b/customerio/__init__.py @@ -7,14 +7,17 @@ SendSMSRequest, SendWhatsAppRequest, ) -from customerio.client_base import CustomerIOException +from customerio.client import Client +from customerio.client_base import CustomerIOException, KeyCapabilityError from customerio.regions import Regions from customerio.track import CustomerIO __all__ = [ "APIClient", + "Client", "CustomerIO", "CustomerIOException", + "KeyCapabilityError", "Regions", "SendEmailRequest", "SendInAppRequest", diff --git a/customerio/client.py b/customerio/client.py new file mode 100644 index 0000000..36b4d7b --- /dev/null +++ b/customerio/client.py @@ -0,0 +1,203 @@ +""" +Implements a single client for the Track and App APIs, authenticated with one key. +""" + +import re +from functools import wraps +from urllib.parse import urlsplit + +from .api import APIClient +from .client_base import CustomerIOException, KeyCapabilityError +from .regions import Region, Regions +from .track import CustomerIO + +# Keys in the shared format look like `{ak|wk}_{us|eu}_{random}_{checksum}`. +# The server checks the checksum; the client only reads the prefix to pick a host. +KEY_PREFIX = re.compile(r"^(ak|wk)_(us|eu)_") + +CAPABILITY_ACTIONS = { + "track": "send tracking data", + "transactional": "send transactional messages", +} + + +class _BearerTrackClient(CustomerIO): + """Track API client that sends the key as a Bearer token instead of Basic auth.""" + + def _build_session(self): + session = super()._build_session() + session.auth = None + session.headers["Authorization"] = f"Bearer {self.api_key}" + return session + + +def _guard(capability): + """Re-raises a 401 or 403 as a KeyCapabilityError so a key mismatch is never a bare + auth failure.""" + + def decorator(method): + @wraps(method) + def wrapper(*args, **kwargs): + try: + return method(*args, **kwargs) + except KeyCapabilityError: + raise + except CustomerIOException as e: + status = getattr(e, "status_code", None) + if status not in (401, 403): + raise + message = ( + f"This key can't {CAPABILITY_ACTIONS[capability]} " + f"({status}): {_server_message(e.response)}" + ) + raise KeyCapabilityError(capability, message, status_code=status) from e + + return wrapper + + return decorator + + +def _server_message(response): + try: + return response.json()["meta"]["error"] + except Exception: + return response.text + + +class Client: + """One client for tracking and transactional messages, authenticated with a single key. + + Tracking methods (``identify``, ``track``, ``track_anonymous``, ``pageview``) go to + the Track API; ``send_*`` methods go to the App API. Both send the key as a Bearer + token. For ``ak_us_…`` / ``ak_eu_…`` keys the region comes from the key; an explicit + ``region`` wins. Legacy keys default to ``Regions.US``. + + When the key can't do what a method asks, the method raises a + :class:`KeyCapabilityError`: before the request when the key's type says so (a + public ``wk_`` key can't send transactional messages), otherwise when the server + answers 401 or 403. + + Other Track and App API methods are on ``track_client`` and ``api_client``. + """ + + def __init__( + self, + api_key, + region=None, + track_url=None, + api_url=None, + retries=3, + timeout=10, + backoff_factor=0.02, + use_connection_pooling=True, + ): + if not api_key: + raise CustomerIOException("api_key is required") + if region is not None and not isinstance(region, Region): + raise CustomerIOException("invalid region provided") + + match = KEY_PREFIX.match(api_key) + self._key_kind = match.group(1) if match else "legacy" + key_region = (Regions.EU if match.group(2) == "eu" else Regions.US) if match else None + self.region = region or key_region or Regions.US + + options = dict( + retries=retries, + timeout=timeout, + backoff_factor=backoff_factor, + use_connection_pooling=use_connection_pooling, + ) + + track_host = track_port = track_prefix = None + if track_url: + parts = urlsplit(track_url) + track_host, track_port, track_prefix = parts.hostname, parts.port, parts.path or None + + #: Track API client, authenticated with the key as a Bearer token. + self.track_client = _BearerTrackClient( + api_key=api_key, + host=track_host, + port=track_port, + url_prefix=track_prefix, + region=self.region, + **options, + ) + self._api_client = APIClient(api_key, url=api_url, region=self.region, **options) + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + def close(self): + try: + self.track_client.close() + finally: + self._api_client.close() + + @property + def api_client(self): + """App API client, authenticated with the key. + + Raises :class:`KeyCapabilityError` if the key is a public ``wk_`` key. + """ + if self._key_kind == "wk": + raise KeyCapabilityError( + "transactional", + "This key can't use the App API: public keys (wk_) are for tracking only. " + "Use a secret key (ak_).", + ) + return self._api_client + + @_guard("track") + def identify(self, id, **kwargs): + """Create or update a person. See :meth:`CustomerIO.identify`.""" + return self.track_client.identify(id, **kwargs) + + @_guard("track") + def track(self, customer_id, name, data=None, id=None, timestamp=None): + """Track an event for a person. See :meth:`CustomerIO.track`.""" + return self.track_client.track(customer_id, name, data=data, id=id, timestamp=timestamp) + + @_guard("track") + def track_anonymous(self, anonymous_id, name, data=None, id=None, timestamp=None): + """Track an event for an anonymous visitor. See :meth:`CustomerIO.track_anonymous`.""" + return self.track_client.track_anonymous( + anonymous_id, name, data=data, id=id, timestamp=timestamp + ) + + @_guard("track") + def pageview(self, customer_id, page, **data): + """Track a page view for a person. See :meth:`CustomerIO.pageview`.""" + return self.track_client.pageview(customer_id, page, **data) + + @_guard("transactional") + def send_email(self, request): + """Send a transactional email. See :meth:`APIClient.send_email`.""" + return self.api_client.send_email(request) + + @_guard("transactional") + def send_push(self, request): + """Send a transactional push. See :meth:`APIClient.send_push`.""" + return self.api_client.send_push(request) + + @_guard("transactional") + def send_sms(self, request): + """Send a transactional SMS. See :meth:`APIClient.send_sms`.""" + return self.api_client.send_sms(request) + + @_guard("transactional") + def send_whatsapp(self, request): + """Send a transactional WhatsApp message. See :meth:`APIClient.send_whatsapp`.""" + return self.api_client.send_whatsapp(request) + + @_guard("transactional") + def send_inbox_message(self, request): + """Send a transactional inbox message. See :meth:`APIClient.send_inbox_message`.""" + return self.api_client.send_inbox_message(request) + + @_guard("transactional") + def send_in_app(self, request): + """Send a transactional in-app message. See :meth:`APIClient.send_in_app`.""" + return self.api_client.send_in_app(request) diff --git a/customerio/client_base.py b/customerio/client_base.py index 4e63dd7..6c7bbe8 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -50,6 +50,23 @@ class CustomerIOException(Exception): pass +class KeyCapabilityError(CustomerIOException): + """Raised by :class:`customerio.Client` when its key can't do what a method asks. + + Raised before the request when the key's type rules it out (a public ``wk_`` + key can't send transactional messages). Otherwise the server's 401 or 403 is + re-raised as this error, chained from the original :class:`CustomerIOException`. + + ``capability`` is ``"track"`` or ``"transactional"``. ``status_code`` is the + HTTP status when the server refused the key, else ``None``. + """ + + def __init__(self, capability, message, status_code=None): + super().__init__(message) + self.capability = capability + self.status_code = status_code + + class ClientBase: def __init__(self, retries=3, timeout=10, backoff_factor=0.02, use_connection_pooling=True): self.timeout = timeout @@ -100,7 +117,10 @@ def send_request(self, method, url, data): result_status = response.status_code if result_status < 200 or result_status >= 300: - raise CustomerIOException(f"{result_status}: {url} {data} {response.text}") + error = CustomerIOException(f"{result_status}: {url} {data} {response.text}") + error.status_code = result_status + error.response = response + raise error return response except CustomerIOException: diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..84feea1 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,247 @@ +import json +import unittest +from functools import partial + +import urllib3 + +from customerio import ( + Client, + CustomerIOException, + KeyCapabilityError, + Regions, + SendEmailRequest, + SendInAppRequest, + SendInboxMessageRequest, + SendPushRequest, + SendSMSRequest, + SendWhatsAppRequest, +) +from tests.server import HTTPSTestCase + +# test uses a self signed certificate so disable the warning messages +urllib3.disable_warnings() + +US_KEY = "ak_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" +EU_KEY = "ak_eu_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" +PUBLIC_KEY = "wk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" +LEGACY_KEY = "abc123" + +EMAIL = {"to": "a@example.com", "identifiers": {"id": "user_123"}, "transactional_message_id": 5} + + +class FakeResponse: + def __init__(self, status_code, body): + self.status_code = status_code + self.text = json.dumps(body) + + def json(self): + return json.loads(self.text) + + +class FakeSession: + def __init__(self, response): + self.response = response + self.request_count = 0 + + def request(self, *args, **kwargs): + self.request_count += 1 + return self.response + + def close(self): + pass + + +class TestClientSetup(unittest.TestCase): + def test_requires_api_key(self): + with self.assertRaises(CustomerIOException): + Client(api_key="") + + def test_rejects_invalid_region(self): + with self.assertRaises(CustomerIOException): + Client(api_key=US_KEY, region="eu") + + def test_region_comes_from_the_key(self): + self.assertEqual(Client(api_key=US_KEY).region, Regions.US) + self.assertEqual(Client(api_key=EU_KEY).region, Regions.EU) + self.assertEqual(Client(api_key=PUBLIC_KEY).region, Regions.US) + + cio = Client(api_key=EU_KEY) + self.assertEqual(cio.track_client.base_url, f"https://{Regions.EU.track_host}/api/v1") + self.assertEqual(cio.api_client.url, f"https://{Regions.EU.api_host}") + + def test_explicit_region_wins_over_the_key(self): + cio = Client(api_key=EU_KEY, region=Regions.US) + self.assertEqual(cio.region, Regions.US) + self.assertEqual(cio.track_client.base_url, f"https://{Regions.US.track_host}/api/v1") + self.assertEqual(cio.api_client.url, f"https://{Regions.US.api_host}") + + def test_legacy_keys_keep_the_region_option_and_us_default(self): + self.assertEqual(Client(api_key=LEGACY_KEY).region, Regions.US) + self.assertEqual(Client(api_key=LEGACY_KEY, region=Regions.EU).region, Regions.EU) + + def test_both_clients_use_bearer_auth(self): + cio = Client(api_key=US_KEY) + self.assertIsNone(cio.track_client.http.auth) + self.assertEqual(cio.track_client.http.headers["Authorization"], f"Bearer {US_KEY}") + self.assertEqual(cio.api_client.http.headers["Authorization"], f"Bearer {US_KEY}") + + +class TestClientRequests(HTTPSTestCase): + """Starts server which the client connects to in the following tests""" + + def setUp(self): + url = f"https://{self.server.server_address[0]}:{self.server.server_port}" + self.cio = self._client(US_KEY, url) + + def _client(self, key, url): + cio = Client(api_key=key, track_url=url, api_url=url, retries=5, backoff_factor=0) + + # do not verify the ssl certificate as it is self signed + # should only be done for tests + cio.track_client.http.verify = False + cio._api_client.http.verify = False + return cio + + def _check_request(self, resp, rq, *args, **kwargs): + request = resp.request + self.assertEqual(request.method, rq["method"]) + self.assertEqual(request.headers["Authorization"], rq["authorization"]) + self.assertTrue( + request.url.endswith(rq["url_suffix"]), + "url: {} expected suffix: {}".format(request.url, rq["url_suffix"]), + ) + + def _expect(self, http, method, url_suffix, key=US_KEY): + http.hooks = dict( + response=partial( + self._check_request, + rq={"method": method, "authorization": f"Bearer {key}", "url_suffix": url_suffix}, + ) + ) + + def test_tracking_goes_to_the_track_api_with_bearer(self): + http = self.cio.track_client.http + + self._expect(http, "PUT", "/api/v1/customers/user_123") + self.cio.identify("user_123", email="a@example.com") + + self._expect(http, "POST", "/api/v1/customers/user_123/events") + self.cio.track("user_123", "order_completed", data={"price": 1}) + + self._expect(http, "POST", "/api/v1/events") + self.cio.track_anonymous("anon_1", "viewed") + + self._expect(http, "POST", "/api/v1/customers/user_123/events") + self.cio.pageview("user_123", "/pricing") + + def test_send_methods_go_to_the_app_api_with_bearer(self): + http = self.cio.api_client.http + identifiers = {"id": "user_123"} + sends = [ + (self.cio.send_email, SendEmailRequest(**EMAIL), "email"), + (self.cio.send_push, SendPushRequest(identifiers=identifiers), "push"), + (self.cio.send_sms, SendSMSRequest(identifiers=identifiers), "sms"), + (self.cio.send_whatsapp, SendWhatsAppRequest(identifiers=identifiers), "whatsapp"), + ( + self.cio.send_inbox_message, + SendInboxMessageRequest(identifiers=identifiers), + "inbox_message", + ), + (self.cio.send_in_app, SendInAppRequest(identifiers=identifiers), "in_app"), + ] + + for send, request, path in sends: + self._expect(http, "POST", f"/v1/send/{path}") + self.assertEqual(send(request), {}) + + self._expect(http, "POST", "/v1/send/email") + self.cio.send_email(dict(EMAIL)) + + def test_public_key_can_still_track(self): + url = f"https://{self.server.server_address[0]}:{self.server.server_port}" + cio = self._client(PUBLIC_KEY, url) + + self._expect(cio.track_client.http, "POST", "/api/v1/customers/user_123/events", PUBLIC_KEY) + cio.track("user_123", "viewed") + + +class TestClientKeyCapability(unittest.TestCase): + def _fail_with(self, client, status, body): + session = FakeSession(FakeResponse(status, body)) + client._build_session = lambda: session + client._current_session = None + return session + + def test_public_key_raises_on_transactional_calls_without_a_request(self): + cio = Client(api_key=PUBLIC_KEY) + session = self._fail_with(cio._api_client, 200, {}) + + for send in [ + cio.send_email, + cio.send_push, + cio.send_sms, + cio.send_whatsapp, + cio.send_inbox_message, + cio.send_in_app, + ]: + with self.assertRaises(KeyCapabilityError) as ctx: + send(dict(EMAIL)) + self.assertEqual(ctx.exception.capability, "transactional") + self.assertIsNone(ctx.exception.status_code) + self.assertIn("public keys (wk_) are for tracking only", str(ctx.exception)) + + with self.assertRaises(KeyCapabilityError): + cio.api_client # noqa: B018 + + self.assertEqual(session.request_count, 0) + + def test_401_from_the_app_api_becomes_key_capability_error(self): + cio = Client(api_key=US_KEY) + self._fail_with(cio._api_client, 401, {"meta": {"error": "unauthorized"}}) + + with self.assertRaises(KeyCapabilityError) as ctx: + cio.send_email(dict(EMAIL)) + + self.assertEqual(ctx.exception.capability, "transactional") + self.assertEqual(ctx.exception.status_code, 401) + self.assertEqual( + str(ctx.exception), "This key can't send transactional messages (401): unauthorized" + ) + self.assertIsInstance(ctx.exception.__cause__, CustomerIOException) + self.assertNotIsInstance(ctx.exception.__cause__, KeyCapabilityError) + + def test_403_from_the_track_api_becomes_key_capability_error(self): + cio = Client(api_key=US_KEY) + self._fail_with(cio.track_client, 403, {"meta": {"error": "forbidden"}}) + + with self.assertRaises(KeyCapabilityError) as ctx: + cio.identify("1") + + self.assertEqual(ctx.exception.capability, "track") + self.assertEqual(ctx.exception.status_code, 403) + self.assertEqual(str(ctx.exception), "This key can't send tracking data (403): forbidden") + self.assertIsInstance(ctx.exception.__cause__, CustomerIOException) + + def test_other_request_errors_pass_through_unchanged(self): + cio = Client(api_key=US_KEY) + self._fail_with(cio.track_client, 400, {"meta": {"error": "bad request"}}) + + with self.assertRaises(CustomerIOException) as ctx: + cio.identify("1") + + self.assertNotIsInstance(ctx.exception, KeyCapabilityError) + self.assertIn("400", str(ctx.exception)) + + def test_missing_params_still_raise_before_a_request(self): + cio = Client(api_key=US_KEY) + session = self._fail_with(cio.track_client, 200, {}) + + with self.assertRaises(CustomerIOException) as ctx: + cio.identify("") + + self.assertNotIsInstance(ctx.exception, KeyCapabilityError) + self.assertEqual(session.request_count, 0) + + +if __name__ == "__main__": + unittest.main() From 748f95173e937266243612511ef6e340b3157967 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:11:59 -0400 Subject: [PATCH 2/8] feat: rename API key prefixes to pk_/sk_ Read the region from any key shaped {xx}_{us|eu}_ and treat pk_ as the public key. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- customerio/client.py | 19 ++++++++++--------- customerio/client_base.py | 2 +- tests/test_client.py | 8 ++++---- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 12ab185..0cd4512 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ cio.track(customer_id="5", name="purchased", data={"price": 23.45}) ### Quick start -Create a client with your secret key (`ak_…`). It sends tracking calls to the Track API and transactional messages to the App API, and picks the US or EU region from the key. +Create a client with your secret key (`sk_…`). It sends tracking calls to the Track API and transactional messages to the App API, and picks the US or EU region from the key. ```python import os @@ -57,7 +57,7 @@ Pass `region` to override the region in the key. Legacy App API keys also work h `identify`, `track`, `track_anonymous`, `pageview` and the `send_*` methods are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. -If the key can't do what you asked, the call raises a `KeyCapabilityError` (a subclass of `CustomerIOException`). A public key (`wk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, chained from the original error. +If the key can't do what you asked, the call raises a `KeyCapabilityError` (a subclass of `CustomerIOException`). A public key (`pk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, chained from the original error. ```python from customerio import KeyCapabilityError diff --git a/customerio/client.py b/customerio/client.py index 36b4d7b..edfa51e 100644 --- a/customerio/client.py +++ b/customerio/client.py @@ -11,9 +11,10 @@ from .regions import Region, Regions from .track import CustomerIO -# Keys in the shared format look like `{ak|wk}_{us|eu}_{random}_{checksum}`. -# The server checks the checksum; the client only reads the prefix to pick a host. -KEY_PREFIX = re.compile(r"^(ak|wk)_(us|eu)_") +# Keys in the shared format look like `{type}_{us|eu}_{random}_{checksum}`, where type +# is `pk` (public) or `sk` (secret). The server checks the checksum; the client only +# reads the prefix to pick a host and spot public keys. +KEY_PREFIX = re.compile(r"^([a-z]{2})_(us|eu)_") CAPABILITY_ACTIONS = { "track": "send tracking data", @@ -69,12 +70,12 @@ class Client: Tracking methods (``identify``, ``track``, ``track_anonymous``, ``pageview``) go to the Track API; ``send_*`` methods go to the App API. Both send the key as a Bearer - token. For ``ak_us_…`` / ``ak_eu_…`` keys the region comes from the key; an explicit + token. For ``sk_us_…`` / ``sk_eu_…`` keys the region comes from the key; an explicit ``region`` wins. Legacy keys default to ``Regions.US``. When the key can't do what a method asks, the method raises a :class:`KeyCapabilityError`: before the request when the key's type says so (a - public ``wk_`` key can't send transactional messages), otherwise when the server + public ``pk_`` key can't send transactional messages), otherwise when the server answers 401 or 403. Other Track and App API methods are on ``track_client`` and ``api_client``. @@ -140,13 +141,13 @@ def close(self): def api_client(self): """App API client, authenticated with the key. - Raises :class:`KeyCapabilityError` if the key is a public ``wk_`` key. + Raises :class:`KeyCapabilityError` if the key is a public ``pk_`` key. """ - if self._key_kind == "wk": + if self._key_kind == "pk": raise KeyCapabilityError( "transactional", - "This key can't use the App API: public keys (wk_) are for tracking only. " - "Use a secret key (ak_).", + "This key can't use the App API: public keys (pk_) are for tracking only. " + "Use a secret key (sk_).", ) return self._api_client diff --git a/customerio/client_base.py b/customerio/client_base.py index 6c7bbe8..73e376a 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -53,7 +53,7 @@ class CustomerIOException(Exception): class KeyCapabilityError(CustomerIOException): """Raised by :class:`customerio.Client` when its key can't do what a method asks. - Raised before the request when the key's type rules it out (a public ``wk_`` + Raised before the request when the key's type rules it out (a public ``pk_`` key can't send transactional messages). Otherwise the server's 401 or 403 is re-raised as this error, chained from the original :class:`CustomerIOException`. diff --git a/tests/test_client.py b/tests/test_client.py index 84feea1..6953361 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -21,9 +21,9 @@ # test uses a self signed certificate so disable the warning messages urllib3.disable_warnings() -US_KEY = "ak_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" -EU_KEY = "ak_eu_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" -PUBLIC_KEY = "wk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" +US_KEY = "sk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" +EU_KEY = "sk_eu_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" +PUBLIC_KEY = "pk_us_0123456789ABCDEFGHIJKLMNOPQRSTUV_000000" LEGACY_KEY = "abc123" EMAIL = {"to": "a@example.com", "identifiers": {"id": "user_123"}, "transactional_message_id": 5} @@ -188,7 +188,7 @@ def test_public_key_raises_on_transactional_calls_without_a_request(self): send(dict(EMAIL)) self.assertEqual(ctx.exception.capability, "transactional") self.assertIsNone(ctx.exception.status_code) - self.assertIn("public keys (wk_) are for tracking only", str(ctx.exception)) + self.assertIn("public keys (pk_) are for tracking only", str(ctx.exception)) with self.assertRaises(KeyCapabilityError): cio.api_client # noqa: B018 From c103316189f21445cea3bb69f513f79ca4cea851 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:15:36 -0400 Subject: [PATCH 3/8] fix: keep the /api/v1 prefix when track_url ends with a slash Co-Authored-By: Claude Opus 5.5 --- customerio/client.py | 4 +++- tests/test_client.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/customerio/client.py b/customerio/client.py index edfa51e..7d53b6f 100644 --- a/customerio/client.py +++ b/customerio/client.py @@ -112,7 +112,9 @@ def __init__( track_host = track_port = track_prefix = None if track_url: parts = urlsplit(track_url) - track_host, track_port, track_prefix = parts.hostname, parts.port, parts.path or None + track_host, track_port = parts.hostname, parts.port + # A bare "/" path falls back to the default /api/v1 prefix. + track_prefix = parts.path.rstrip("/") or None #: Track API client, authenticated with the key as a Bearer token. self.track_client = _BearerTrackClient( diff --git a/tests/test_client.py b/tests/test_client.py index 6953361..28b1eb9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -75,6 +75,15 @@ def test_explicit_region_wins_over_the_key(self): self.assertEqual(cio.track_client.base_url, f"https://{Regions.US.track_host}/api/v1") self.assertEqual(cio.api_client.url, f"https://{Regions.US.api_host}") + def test_track_url_without_a_path_keeps_the_api_prefix(self): + for url in ("https://track.example.com", "https://track.example.com/"): + cio = Client(api_key=US_KEY, track_url=url) + self.assertEqual(cio.track_client.base_url, "https://track.example.com/api/v1") + + def test_track_url_with_a_path_uses_it_as_the_prefix(self): + cio = Client(api_key=US_KEY, track_url="https://track.example.com/custom/") + self.assertEqual(cio.track_client.base_url, "https://track.example.com/custom") + def test_legacy_keys_keep_the_region_option_and_us_default(self): self.assertEqual(Client(api_key=LEGACY_KEY).region, Regions.US) self.assertEqual(Client(api_key=LEGACY_KEY, region=Regions.EU).region, Regions.EU) From 72295df2b968f57c287fddb23ac3bb2603d1aa85 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:03:24 -0400 Subject: [PATCH 4/8] fix: send the Bearer token through an auth handler and soften refusal errors A matching .netrc entry could replace the Bearer header with Basic auth. 401/403 errors no longer blame the key, and the README says which calls legacy keys still cover. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- customerio/client.py | 26 ++++++++++++++++++++------ customerio/client_base.py | 4 ++-- tests/test_client.py | 26 ++++++++++++++++++++++---- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0cd4512..15c1067 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,11 @@ cio.send_email( ) ``` -Pass `region` to override the region in the key. Legacy App API keys also work here; they default to `Regions.US` unless you pass `region`. +Pass `region` to override the region in the key. Legacy App API keys still work for transactional and other App API calls, and default to `Regions.US` unless you pass `region`. Tracking calls send the key as a Bearer token, so on Pipelines workspaces they need a new secret key (`sk_…`) tied to a source. `identify`, `track`, `track_anonymous`, `pageview` and the `send_*` methods are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. -If the key can't do what you asked, the call raises a `KeyCapabilityError` (a subclass of `CustomerIOException`). A public key (`pk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, chained from the original error. +If a call is refused, it raises a `KeyCapabilityError` (a subclass of `CustomerIOException`). A public key (`pk_…`) raises it before any request when you send a transactional message. A 401 or 403 from the server is re-raised as a `KeyCapabilityError` too, chained from the original error. A 401 or 403 can come from the key, its permissions, an IP allowlist or a disabled workspace. ```python from customerio import KeyCapabilityError @@ -66,7 +66,7 @@ try: cio.send_email(request) except KeyCapabilityError as e: # e.capability == "transactional", e.status_code == 401 - print(e) # This key can't send transactional messages (401): ... + print(e) # The request to send transactional messages was refused (401): ... ``` ### Instantiating customer.io object diff --git a/customerio/client.py b/customerio/client.py index 7d53b6f..2ba5ad1 100644 --- a/customerio/client.py +++ b/customerio/client.py @@ -6,6 +6,8 @@ from functools import wraps from urllib.parse import urlsplit +from requests.auth import AuthBase + from .api import APIClient from .client_base import CustomerIOException, KeyCapabilityError from .regions import Region, Regions @@ -22,18 +24,29 @@ } +class _BearerAuth(AuthBase): + """Sends the key as a Bearer token. Set as ``session.auth`` so Requests never swaps + in Basic auth from a matching ``.netrc`` entry.""" + + def __init__(self, key): + self.key = key + + def __call__(self, request): + request.headers["Authorization"] = f"Bearer {self.key}" + return request + + class _BearerTrackClient(CustomerIO): """Track API client that sends the key as a Bearer token instead of Basic auth.""" def _build_session(self): session = super()._build_session() - session.auth = None - session.headers["Authorization"] = f"Bearer {self.api_key}" + session.auth = _BearerAuth(self.api_key) return session def _guard(capability): - """Re-raises a 401 or 403 as a KeyCapabilityError so a key mismatch is never a bare + """Re-raises a 401 or 403 as a KeyCapabilityError so a refusal is never a bare auth failure.""" def decorator(method): @@ -48,8 +61,9 @@ def wrapper(*args, **kwargs): if status not in (401, 403): raise message = ( - f"This key can't {CAPABILITY_ACTIONS[capability]} " - f"({status}): {_server_message(e.response)}" + f"The request to {CAPABILITY_ACTIONS[capability]} was refused ({status}): " + f"{_server_message(e.response)}. Check the key, its permissions, the IP " + "allowlist and the workspace status." ) raise KeyCapabilityError(capability, message, status_code=status) from e @@ -73,7 +87,7 @@ class Client: token. For ``sk_us_…`` / ``sk_eu_…`` keys the region comes from the key; an explicit ``region`` wins. Legacy keys default to ``Regions.US``. - When the key can't do what a method asks, the method raises a + When a call is refused, the method raises a :class:`KeyCapabilityError`: before the request when the key's type says so (a public ``pk_`` key can't send transactional messages), otherwise when the server answers 401 or 403. diff --git a/customerio/client_base.py b/customerio/client_base.py index 73e376a..df443b4 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -51,14 +51,14 @@ class CustomerIOException(Exception): class KeyCapabilityError(CustomerIOException): - """Raised by :class:`customerio.Client` when its key can't do what a method asks. + """Raised by :class:`customerio.Client` when a call is refused for its key. Raised before the request when the key's type rules it out (a public ``pk_`` key can't send transactional messages). Otherwise the server's 401 or 403 is re-raised as this error, chained from the original :class:`CustomerIOException`. ``capability`` is ``"track"`` or ``"transactional"``. ``status_code`` is the - HTTP status when the server refused the key, else ``None``. + HTTP status when the server refused the request, else ``None``. """ def __init__(self, capability, message, status_code=None): diff --git a/tests/test_client.py b/tests/test_client.py index 28b1eb9..5b3ace0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,9 @@ import json import unittest from functools import partial +from unittest import mock +import requests import urllib3 from customerio import ( @@ -84,14 +86,24 @@ def test_track_url_with_a_path_uses_it_as_the_prefix(self): cio = Client(api_key=US_KEY, track_url="https://track.example.com/custom/") self.assertEqual(cio.track_client.base_url, "https://track.example.com/custom") + def test_netrc_entry_does_not_replace_the_bearer_token(self): + cio = Client(api_key=US_KEY) + with mock.patch("requests.sessions.get_netrc_auth", return_value=("user", "pass")): + prepared = cio.track_client.http.prepare_request( + requests.Request("PUT", cio.track_client.get_customer_query_string("1")) + ) + self.assertEqual(prepared.headers["Authorization"], f"Bearer {US_KEY}") + def test_legacy_keys_keep_the_region_option_and_us_default(self): self.assertEqual(Client(api_key=LEGACY_KEY).region, Regions.US) self.assertEqual(Client(api_key=LEGACY_KEY, region=Regions.EU).region, Regions.EU) def test_both_clients_use_bearer_auth(self): cio = Client(api_key=US_KEY) - self.assertIsNone(cio.track_client.http.auth) - self.assertEqual(cio.track_client.http.headers["Authorization"], f"Bearer {US_KEY}") + track_request = cio.track_client.http.prepare_request( + requests.Request("PUT", cio.track_client.get_customer_query_string("1")) + ) + self.assertEqual(track_request.headers["Authorization"], f"Bearer {US_KEY}") self.assertEqual(cio.api_client.http.headers["Authorization"], f"Bearer {US_KEY}") @@ -214,7 +226,9 @@ def test_401_from_the_app_api_becomes_key_capability_error(self): self.assertEqual(ctx.exception.capability, "transactional") self.assertEqual(ctx.exception.status_code, 401) self.assertEqual( - str(ctx.exception), "This key can't send transactional messages (401): unauthorized" + str(ctx.exception), + "The request to send transactional messages was refused (401): unauthorized. " + "Check the key, its permissions, the IP allowlist and the workspace status.", ) self.assertIsInstance(ctx.exception.__cause__, CustomerIOException) self.assertNotIsInstance(ctx.exception.__cause__, KeyCapabilityError) @@ -228,7 +242,11 @@ def test_403_from_the_track_api_becomes_key_capability_error(self): self.assertEqual(ctx.exception.capability, "track") self.assertEqual(ctx.exception.status_code, 403) - self.assertEqual(str(ctx.exception), "This key can't send tracking data (403): forbidden") + self.assertEqual( + str(ctx.exception), + "The request to send tracking data was refused (403): forbidden. " + "Check the key, its permissions, the IP allowlist and the workspace status.", + ) self.assertIsInstance(ctx.exception.__cause__, CustomerIOException) def test_other_request_errors_pass_through_unchanged(self): From 63fe8024fb77fa3de5c88a5c42bcfcd78f2a2eb1 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:53:08 -0400 Subject: [PATCH 5/8] fix: keep IPv6 brackets in track_url and refuse non-https schemes Co-Authored-By: Claude Sonnet 5.5 --- customerio/client.py | 5 +++++ tests/test_client.py | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/customerio/client.py b/customerio/client.py index 2ba5ad1..0a84f58 100644 --- a/customerio/client.py +++ b/customerio/client.py @@ -126,7 +126,12 @@ def __init__( track_host = track_port = track_prefix = None if track_url: parts = urlsplit(track_url) + if parts.scheme != "https": + raise CustomerIOException("track_url must start with https://") track_host, track_port = parts.hostname, parts.port + # Requests rejects an IPv6 literal without its brackets. + if track_host and ":" in track_host: + track_host = f"[{track_host}]" # A bare "/" path falls back to the default /api/v1 prefix. track_prefix = parts.path.rstrip("/") or None diff --git a/tests/test_client.py b/tests/test_client.py index 5b3ace0..df17359 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -86,6 +86,19 @@ def test_track_url_with_a_path_uses_it_as_the_prefix(self): cio = Client(api_key=US_KEY, track_url="https://track.example.com/custom/") self.assertEqual(cio.track_client.base_url, "https://track.example.com/custom") + def test_track_url_keeps_the_brackets_around_an_ipv6_host(self): + cio = Client(api_key=US_KEY, track_url="https://[::1]:8443") + self.assertEqual(cio.track_client.base_url, "https://[::1]:8443/api/v1") + + def test_track_url_with_an_explicit_prefix_and_port_is_kept(self): + cio = Client(api_key=US_KEY, track_url="https://localhost:8080/api/v1") + self.assertEqual(cio.track_client.base_url, "https://localhost:8080/api/v1") + + def test_track_url_that_is_not_https_is_refused(self): + for url in ("http://localhost:8080/api/v1", "localhost:8080"): + with self.assertRaises(CustomerIOException): + Client(api_key=US_KEY, track_url=url) + def test_netrc_entry_does_not_replace_the_bearer_token(self): cio = Client(api_key=US_KEY) with mock.patch("requests.sessions.get_netrc_auth", return_value=("user", "pass")): From 463aa47db5d47af467a5c30552623d6ad4fbdab2 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:16:08 -0400 Subject: [PATCH 6/8] fix: strip the key, refuse a mismatched region and non-https api_url, and use the Bearer auth handler for send_* Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- customerio/client.py | 39 +++++++++++++++++++++++++++++++++------ tests/test_client.py | 40 ++++++++++++++++++++++++++++++++++------ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 15c1067..a980b9a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ cio.send_email( ) ``` -Pass `region` to override the region in the key. Legacy App API keys still work for transactional and other App API calls, and default to `Regions.US` unless you pass `region`. Tracking calls send the key as a Bearer token, so on Pipelines workspaces they need a new secret key (`sk_…`) tied to a source. +A `region` that disagrees with the key's region raises an error. Legacy App API keys still work for transactional and other App API calls, and default to `Regions.US` unless you pass `region`. Tracking calls send the key as a Bearer token, so on Pipelines workspaces they need a new secret key (`sk_…`) tied to a source. `identify`, `track`, `track_anonymous`, `pageview` and the `send_*` methods are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. diff --git a/customerio/client.py b/customerio/client.py index 0a84f58..8d9973b 100644 --- a/customerio/client.py +++ b/customerio/client.py @@ -36,6 +36,16 @@ def __call__(self, request): return request +class _BearerAPIClient(APIClient): + """App API client that sends the key through :class:`_BearerAuth` as well, so a + matching ``.netrc`` entry can't replace the header either.""" + + def _build_session(self): + session = super()._build_session() + session.auth = _BearerAuth(self.key) + return session + + class _BearerTrackClient(CustomerIO): """Track API client that sends the key as a Bearer token instead of Basic auth.""" @@ -73,19 +83,28 @@ def wrapper(*args, **kwargs): def _server_message(response): + # The App API answers {"errors": [{"detail": ...}]}; the Track API uses meta.error. try: - return response.json()["meta"]["error"] + body = response.json() + if body.get("errors"): + return body["errors"][0]["detail"] + return body["meta"]["error"] except Exception: return response.text +def _require_https(name, url): + if urlsplit(url).scheme != "https": + raise CustomerIOException(f"{name} must start with https://") + + class Client: """One client for tracking and transactional messages, authenticated with a single key. Tracking methods (``identify``, ``track``, ``track_anonymous``, ``pageview``) go to the Track API; ``send_*`` methods go to the App API. Both send the key as a Bearer - token. For ``sk_us_…`` / ``sk_eu_…`` keys the region comes from the key; an explicit - ``region`` wins. Legacy keys default to ``Regions.US``. + token. For ``sk_us_…`` / ``sk_eu_…`` keys the region comes from the key, and a + ``region`` that disagrees with it raises. Legacy keys default to ``Regions.US``. When a call is refused, the method raises a :class:`KeyCapabilityError`: before the request when the key's type says so (a @@ -106,6 +125,9 @@ def __init__( backoff_factor=0.02, use_connection_pooling=True, ): + # A key read from a file often ends in a newline, which Requests rejects and + # then copies, key included, into the exception message. + api_key = api_key.strip() if isinstance(api_key, str) else api_key if not api_key: raise CustomerIOException("api_key is required") if region is not None and not isinstance(region, Region): @@ -114,6 +136,10 @@ def __init__( match = KEY_PREFIX.match(api_key) self._key_kind = match.group(1) if match else "legacy" key_region = (Regions.EU if match.group(2) == "eu" else Regions.US) if match else None + if key_region and region and region != key_region: + raise CustomerIOException( + f"region {region.name} does not match the {key_region.name} region of this key" + ) self.region = region or key_region or Regions.US options = dict( @@ -126,8 +152,7 @@ def __init__( track_host = track_port = track_prefix = None if track_url: parts = urlsplit(track_url) - if parts.scheme != "https": - raise CustomerIOException("track_url must start with https://") + _require_https("track_url", track_url) track_host, track_port = parts.hostname, parts.port # Requests rejects an IPv6 literal without its brackets. if track_host and ":" in track_host: @@ -144,7 +169,9 @@ def __init__( region=self.region, **options, ) - self._api_client = APIClient(api_key, url=api_url, region=self.region, **options) + if api_url: + _require_https("api_url", api_url) + self._api_client = _BearerAPIClient(api_key, url=api_url, region=self.region, **options) def __enter__(self): return self diff --git a/tests/test_client.py b/tests/test_client.py index df17359..9288ae4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -71,11 +71,25 @@ def test_region_comes_from_the_key(self): self.assertEqual(cio.track_client.base_url, f"https://{Regions.EU.track_host}/api/v1") self.assertEqual(cio.api_client.url, f"https://{Regions.EU.api_host}") - def test_explicit_region_wins_over_the_key(self): - cio = Client(api_key=EU_KEY, region=Regions.US) - self.assertEqual(cio.region, Regions.US) - self.assertEqual(cio.track_client.base_url, f"https://{Regions.US.track_host}/api/v1") - self.assertEqual(cio.api_client.url, f"https://{Regions.US.api_host}") + def test_region_that_disagrees_with_the_key_raises(self): + for key, region in ((EU_KEY, Regions.US), (US_KEY, Regions.EU)): + with self.assertRaises(CustomerIOException) as ctx: + Client(api_key=key, region=region) + self.assertIn("does not match", str(ctx.exception)) + + def test_region_that_matches_the_key_is_accepted(self): + self.assertEqual(Client(api_key=EU_KEY, region=Regions.EU).region, Regions.EU) + + def test_key_is_stripped_so_it_never_reaches_an_error_message(self): + cio = Client(api_key=US_KEY + "\n") + self.assertEqual(cio.track_client.api_key, US_KEY) + self.assertEqual(cio._api_client.key, US_KEY) + prepared = cio._api_client.http.prepare_request( + requests.Request("POST", "https://api.customer.io/v1/send/email") + ) + self.assertEqual(prepared.headers["Authorization"], f"Bearer {US_KEY}") + with self.assertRaises(CustomerIOException): + Client(api_key=" \n") def test_track_url_without_a_path_keeps_the_api_prefix(self): for url in ("https://track.example.com", "https://track.example.com/"): @@ -99,6 +113,18 @@ def test_track_url_that_is_not_https_is_refused(self): with self.assertRaises(CustomerIOException): Client(api_key=US_KEY, track_url=url) + def test_api_url_that_is_not_https_is_refused(self): + with self.assertRaises(CustomerIOException): + Client(api_key=US_KEY, api_url="http://localhost:8080") + + def test_netrc_entry_does_not_replace_the_bearer_token_on_the_app_api(self): + cio = Client(api_key=US_KEY) + with mock.patch("requests.sessions.get_netrc_auth", return_value=("user", "pass")): + prepared = cio._api_client.http.prepare_request( + requests.Request("POST", "https://api.customer.io/v1/send/email") + ) + self.assertEqual(prepared.headers["Authorization"], f"Bearer {US_KEY}") + def test_netrc_entry_does_not_replace_the_bearer_token(self): cio = Client(api_key=US_KEY) with mock.patch("requests.sessions.get_netrc_auth", return_value=("user", "pass")): @@ -231,7 +257,9 @@ def test_public_key_raises_on_transactional_calls_without_a_request(self): def test_401_from_the_app_api_becomes_key_capability_error(self): cio = Client(api_key=US_KEY) - self._fail_with(cio._api_client, 401, {"meta": {"error": "unauthorized"}}) + self._fail_with( + cio._api_client, 401, {"errors": [{"detail": "unauthorized", "status": "401"}]} + ) with self.assertRaises(KeyCapabilityError) as ctx: cio.send_email(dict(EMAIL)) From a39fc9b1ead4886d0a3a4870120cfeae792c6f74 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:33:36 -0400 Subject: [PATCH 7/8] fix: accept a custom Region with a prefixed key, matching the Node client Co-Authored-By: Claude Opus 5.5 --- customerio/client.py | 4 +++- tests/test_client.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/customerio/client.py b/customerio/client.py index 8d9973b..089d1e9 100644 --- a/customerio/client.py +++ b/customerio/client.py @@ -136,7 +136,9 @@ def __init__( match = KEY_PREFIX.match(api_key) self._key_kind = match.group(1) if match else "legacy" key_region = (Regions.EU if match.group(2) == "eu" else Regions.US) if match else None - if key_region and region and region != key_region: + # A prefixed key belongs to one region, so a different built-in one can only fail. + # A custom Region (a proxy or test server) is left alone. + if key_region and region in (Regions.US, Regions.EU) and region != key_region: raise CustomerIOException( f"region {region.name} does not match the {key_region.name} region of this key" ) diff --git a/tests/test_client.py b/tests/test_client.py index 9288ae4..0579e77 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,6 +18,7 @@ SendSMSRequest, SendWhatsAppRequest, ) +from customerio.regions import Region from tests.server import HTTPSTestCase # test uses a self signed certificate so disable the warning messages @@ -77,6 +78,12 @@ def test_region_that_disagrees_with_the_key_raises(self): Client(api_key=key, region=region) self.assertIn("does not match", str(ctx.exception)) + def test_custom_region_is_accepted_with_a_prefixed_key(self): + custom = Region("test", "track.example.com", "api.example.com") + cio = Client(api_key=EU_KEY, region=custom) + self.assertEqual(cio.region, custom) + self.assertEqual(cio.track_client.base_url, "https://track.example.com/api/v1") + def test_region_that_matches_the_key_is_accepted(self): self.assertEqual(Client(api_key=EU_KEY, region=Regions.EU).region, Regions.EU) From 1dce34393e79250ca43892479953fa267c2f3970 Mon Sep 17 00:00:00 2001 From: sydneycollins-cio <149523950+sydneycollins-cio@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:45:58 -0400 Subject: [PATCH 8/8] fix: treat only pk_/sk_ as prefixed keys, refuse malformed URLs, and drop a trailing slash on api_url Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- customerio/client.py | 21 +++++++++++++++------ tests/test_client.py | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a980b9a..4de4ec0 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ cio.send_email( ) ``` -A `region` that disagrees with the key's region raises an error. Legacy App API keys still work for transactional and other App API calls, and default to `Regions.US` unless you pass `region`. Tracking calls send the key as a Bearer token, so on Pipelines workspaces they need a new secret key (`sk_…`) tied to a source. +Passing `Regions.US` or `Regions.EU` when it disagrees with the key's region raises an error. A custom `Region` is accepted. Legacy App API keys still work for transactional and other App API calls, and default to `Regions.US` unless you pass `region`. Tracking calls send the key as a Bearer token, so on Pipelines workspaces they need a new secret key (`sk_…`) tied to a source. `identify`, `track`, `track_anonymous`, `pageview` and the `send_*` methods are on the client directly. The rest of the Track and App API are on `cio.track_client` and `cio.api_client`. diff --git a/customerio/client.py b/customerio/client.py index 089d1e9..fed7694 100644 --- a/customerio/client.py +++ b/customerio/client.py @@ -16,7 +16,7 @@ # Keys in the shared format look like `{type}_{us|eu}_{random}_{checksum}`, where type # is `pk` (public) or `sk` (secret). The server checks the checksum; the client only # reads the prefix to pick a host and spot public keys. -KEY_PREFIX = re.compile(r"^([a-z]{2})_(us|eu)_") +KEY_PREFIX = re.compile(r"^(pk|sk)_(us|eu)_") CAPABILITY_ACTIONS = { "track": "send tracking data", @@ -94,8 +94,15 @@ def _server_message(response): def _require_https(name, url): - if urlsplit(url).scheme != "https": + try: + parts = urlsplit(url) + # Reading the port is what checks it. + parts.port # noqa: B018 + except ValueError: + raise CustomerIOException(f"{name} is not a valid URL") from None + if parts.scheme != "https": raise CustomerIOException(f"{name} must start with https://") + return parts class Client: @@ -103,8 +110,9 @@ class Client: Tracking methods (``identify``, ``track``, ``track_anonymous``, ``pageview``) go to the Track API; ``send_*`` methods go to the App API. Both send the key as a Bearer - token. For ``sk_us_…`` / ``sk_eu_…`` keys the region comes from the key, and a - ``region`` that disagrees with it raises. Legacy keys default to ``Regions.US``. + token. For ``sk_us_…`` / ``sk_eu_…`` keys the region comes from the key: passing the + other built-in region (``Regions.US`` or ``Regions.EU``) raises, and a custom + ``Region`` is accepted. Legacy keys default to ``Regions.US``. When a call is refused, the method raises a :class:`KeyCapabilityError`: before the request when the key's type says so (a @@ -153,8 +161,7 @@ def __init__( track_host = track_port = track_prefix = None if track_url: - parts = urlsplit(track_url) - _require_https("track_url", track_url) + parts = _require_https("track_url", track_url) track_host, track_port = parts.hostname, parts.port # Requests rejects an IPv6 literal without its brackets. if track_host and ":" in track_host: @@ -173,6 +180,8 @@ def __init__( ) if api_url: _require_https("api_url", api_url) + # The App API client appends paths that start with "/". + api_url = api_url.rstrip("/") self._api_client = _BearerAPIClient(api_key, url=api_url, region=self.region, **options) def __enter__(self): diff --git a/tests/test_client.py b/tests/test_client.py index 0579e77..6d29c70 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -124,6 +124,18 @@ def test_api_url_that_is_not_https_is_refused(self): with self.assertRaises(CustomerIOException): Client(api_key=US_KEY, api_url="http://localhost:8080") + def test_api_url_trailing_slash_is_dropped(self): + cio = Client(api_key=US_KEY, api_url="https://api.example.com/") + self.assertEqual(cio.api_client.url, "https://api.example.com") + + def test_malformed_urls_are_refused(self): + for url in ("https://h:abc", "https://h:99999", "https://[::1"): + for name in ("track_url", "api_url"): + with self.subTest(name=name, url=url): + with self.assertRaises(CustomerIOException) as ctx: + Client(api_key=US_KEY, **{name: url}) + self.assertEqual(str(ctx.exception), f"{name} is not a valid URL") + def test_netrc_entry_does_not_replace_the_bearer_token_on_the_app_api(self): cio = Client(api_key=US_KEY) with mock.patch("requests.sessions.get_netrc_auth", return_value=("user", "pass")): @@ -144,6 +156,12 @@ def test_legacy_keys_keep_the_region_option_and_us_default(self): self.assertEqual(Client(api_key=LEGACY_KEY).region, Regions.US) self.assertEqual(Client(api_key=LEGACY_KEY, region=Regions.EU).region, Regions.EU) + def test_unknown_prefix_is_a_legacy_key(self): + key = "xx_eu_abc" + self.assertEqual(Client(api_key=key).region, Regions.US) + self.assertEqual(Client(api_key=key, region=Regions.EU).region, Regions.EU) + self.assertEqual(Client(api_key=key)._key_kind, "legacy") + def test_both_clients_use_bearer_auth(self): cio = Client(api_key=US_KEY) track_request = cio.track_client.http.prepare_request(