From d59db34dd94f6a15cfd34d5657e3417de28a2de4 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Wed, 6 May 2026 23:38:54 -0400 Subject: [PATCH 1/2] Add batch() method to CustomerIO track client Wraps the v2 batch endpoint (POST /api/v2/batch) to send multiple operations (identify, event, suppress, etc.) in a single request. Useful for backfills and high-volume updates without hitting rate limits. Closes #96 --- customerio/track.py | 12 ++++++++++ tests/test_customerio.py | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/customerio/track.py b/customerio/track.py index e76d3fc..3c09500 100644 --- a/customerio/track.py +++ b/customerio/track.py @@ -234,6 +234,18 @@ def merge_customers(self, primary_id_type, primary_id, secondary_id_type, second } self.send_request("POST", url, post_data) + def batch(self, operations): + """Send multiple operations in a single request. + + Each operation is a dict with at minimum 'type' and 'action' keys. + See https://customer.io/docs/api/track/#operation/batch + """ + if not operations: + raise CustomerIOException("operations cannot be empty in batch") + + url = self.base_url.replace("/api/v1", "/api/v2/batch") + self.send_request("POST", url, {"batch": operations}) + def _build_session(self): session = super()._build_session() session.auth = (self.site_id, self.api_key) diff --git a/tests/test_customerio.py b/tests/test_customerio.py index 37dd4b0..698802d 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -469,6 +469,58 @@ def test_merge_customers_call(self): secondary_id="", ) + def test_batch_call(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/api/v2/batch", + "body": { + "batch": [ + { + "type": "person", + "action": "identify", + "identifiers": {"id": "1"}, + "attributes": {"name": "Alice"}, + }, + { + "type": "person", + "action": "event", + "identifiers": {"id": "1"}, + "name": "purchase", + }, + ] + }, + }, + ) + ) + + self.cio.batch( + [ + { + "type": "person", + "action": "identify", + "identifiers": {"id": "1"}, + "attributes": {"name": "Alice"}, + }, + { + "type": "person", + "action": "event", + "identifiers": {"id": "1"}, + "name": "purchase", + }, + ] + ) + + with self.assertRaises(CustomerIOException): + self.cio.batch([]) + + with self.assertRaises(CustomerIOException): + self.cio.batch(None) + if __name__ == "__main__": unittest.main() From 4ec6ce45c923c9828d44d2092c211089dc9ea618 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Wed, 6 May 2026 23:50:57 -0400 Subject: [PATCH 2/2] Fix batch URL construction to use host directly The previous approach used str.replace on base_url which silently produced the wrong URL when url_prefix was customized. Build from self.host and self.port instead, matching setup_base_url pattern. --- customerio/track.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/customerio/track.py b/customerio/track.py index 3c09500..3e3ffa9 100644 --- a/customerio/track.py +++ b/customerio/track.py @@ -243,7 +243,10 @@ def batch(self, operations): if not operations: raise CustomerIOException("operations cannot be empty in batch") - url = self.base_url.replace("/api/v1", "/api/v2/batch") + if self.port == 443: + url = f"https://{self.host}/api/v2/batch" + else: + url = f"https://{self.host}:{self.port}/api/v2/batch" self.send_request("POST", url, {"batch": operations}) def _build_session(self):