From 53bfcfa54bca60c044be7ffaf5d5baa35ae56363 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:42:51 +0200 Subject: [PATCH 1/5] feat(http): per-request headers on post --- src/fopost/_http.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/fopost/_http.py b/src/fopost/_http.py index 0ef1176..fd4ceb9 100644 --- a/src/fopost/_http.py +++ b/src/fopost/_http.py @@ -3,7 +3,7 @@ from __future__ import annotations import time -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from email.utils import parsedate_to_datetime from typing import Any from urllib.parse import urlsplit @@ -89,13 +89,15 @@ def request( params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, files: Sequence[tuple[str, tuple[str, bytes, str]]] | None = None, + headers: Mapping[str, str] | None = None, ) -> Any: """Send a request, retrying on 429, and return the decoded body. - ``data`` and ``files`` switch the body to ``multipart/form-data``. + ``data`` and ``files`` switch the body to ``multipart/form-data``; + ``headers`` adds per-request headers such as ``Idempotency-Key``. """ clean_params = {k: v for k, v in params.items() if v is not None} if params else None - headers = self.headers + headers = {**self.headers, **(headers or {})} if files is not None: # httpx sets the multipart boundary itself. headers = {k: v for k, v in headers.items() if k != "Content-Type"} @@ -153,8 +155,10 @@ def _decode(self, response: httpx.Response) -> Any: def get(self, path: str, params: dict[str, Any] | None = None) -> Any: return self.request("GET", path, params=params) - def post(self, path: str, json: Any | None = None) -> Any: - return self.request("POST", path, json=json) + def post( + self, path: str, json: Any | None = None, *, headers: Mapping[str, str] | None = None + ) -> Any: + return self.request("POST", path, json=json, headers=headers) def put(self, path: str, json: Any | None = None) -> Any: return self.request("PUT", path, json=json) From 439bad33d3a458d2eba6d2ff71c7b80f1b33d11b Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:43:10 +0200 Subject: [PATCH 2/5] feat(ads): pixel, conversion batch and custom conversion models --- src/fopost/__init__.py | 28 +++++++++ src/fopost/models.py | 130 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 3f5184e..93ef398 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -103,6 +103,20 @@ ConversationAnalyticsRow, ConversionMetrics, ConversionRule, + ConversionUploadResult, + HashedConversionEvent, + ConversionBatch, + AdPixel, + AdPixelDetail, + PixelEventCount, + PixelStats, + MatchKeyCoverage, + EventMatchQuality, + CustomConversionRule, + CustomConversion, + CONVERSION_ACTION_SOURCES, + CUSTOM_CONVERSION_EVENT_TYPES, + PIXEL_DATA_USE_SETTINGS, DecayBand, Delivery, DiscordChannel, @@ -489,6 +503,20 @@ "BidPricing", "ConversionMetrics", "ConversionRule", + "ConversionUploadResult", + "HashedConversionEvent", + "ConversionBatch", + "AdPixel", + "AdPixelDetail", + "PixelEventCount", + "PixelStats", + "MatchKeyCoverage", + "EventMatchQuality", + "CustomConversionRule", + "CustomConversion", + "CONVERSION_ACTION_SOURCES", + "CUSTOM_CONVERSION_EVENT_TYPES", + "PIXEL_DATA_USE_SETTINGS", "ReachEstimate", "SupplyForecast", "BLOG_ARTICLE_STATUSES", diff --git a/src/fopost/models.py b/src/fopost/models.py index c84d48e..ab643fa 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -1735,6 +1735,136 @@ class ConversionMetrics(FopostModel): cost_per_conversion_minor: int | None = None +CONVERSION_ACTION_SOURCES = ( + "website", + "app", + "email", + "phone_call", + "chat", + "physical_store", + "system_generated", + "business_messaging", + "other", +) + +CUSTOM_CONVERSION_EVENT_TYPES = ( + "ADD_PAYMENT_INFO", + "ADD_TO_CART", + "ADD_TO_WISHLIST", + "COMPLETE_REGISTRATION", + "CONTENT_VIEW", + "INITIATED_CHECKOUT", + "LEAD", + "PURCHASE", + "SEARCH", + "CONTACT", + "CUSTOMIZE_PRODUCT", + "DONATE", + "FIND_LOCATION", + "SCHEDULE", + "START_TRIAL", + "SUBMIT_APPLICATION", + "SUBSCRIBE", + "OTHER", +) + +PIXEL_DATA_USE_SETTINGS = ("advertising_and_analytics", "analytics_only") + + +class ConversionUploadResult(FopostModel): + """What the API answers to a conversion batch; ``replayed`` marks a reused key.""" + + id: str | None = None + accepted: int = 0 + event_count: int = 0 + replayed: bool = False + + +class HashedConversionEvent(FopostModel): + """A stored event. Identifiers are kept as hashes only.""" + + event_name: str + occurred_at: str | None = None + event_id: str | None = None + action_source: str | None = None + email_hash: str | None = None + phone_hash: str | None = None + external_id_hash: str | None = None + value_minor: int | None = None + currency: str | None = None + order_id: str | None = None + + +class ConversionBatch(FopostModel): + id: str + workspace_id: str | None = None + connection_id: str | None = None + ad_account_id: str | None = None + pixel_id: str | None = None + idempotency_key: str | None = None + event_count: int = 0 + accepted: int = 0 + events: list[HashedConversionEvent] = [] + created_at: str | None = None + + +class AdPixel(FopostModel): + id: str + name: str + + +class AdPixelDetail(AdPixel): + code: str | None = None + created_at: str | None = None + last_fired_at: str | None = None + is_unavailable: bool = False + data_use_setting: str | None = None + + +class PixelEventCount(FopostModel): + event_name: str + count: int = 0 + + +class PixelStats(FopostModel): + """Event counts over the last 28 days.""" + + since: str | None = None + until: str | None = None + events: list[PixelEventCount] = [] + + +class MatchKeyCoverage(FopostModel): + identifier: str + coverage_percent: float = 0 + + +class EventMatchQuality(FopostModel): + event_name: str + score: float | None = None + match_keys: list[MatchKeyCoverage] = [] + + +class CustomConversionRule(FopostModel): + event_name: str | None = None + url_contains: str | None = None + url_equals: str | None = None + + +class CustomConversion(FopostModel): + id: str + name: str + pixel_id: str | None = None + event_type: str | None = None + rule: CustomConversionRule | None = None + description: str | None = None + default_value_minor: int | None = None + is_archived: bool = False + is_unavailable: bool = False + created_at: str | None = None + last_fired_at: str | None = None + + class LeadFormDetail(LeadForm): page_id: str | None = None privacy_policy_url: str | None = None From 4dfe1586a4098188f610f19eb7eed8aab6c65837 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:44:02 +0200 Subject: [PATCH 3/5] feat(ads): pixels, Conversions API sends and custom conversions --- src/fopost/__init__.py | 24 +-- src/fopost/resources/ads.py | 338 ++++++++++++++++++++++++++++++++++-- 2 files changed, 337 insertions(+), 25 deletions(-) diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 93ef398..fadd06f 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -35,6 +35,9 @@ BLOG_GOALS, BLOG_KEYWORD_STATUSES, BLOG_TARGET_KINDS, + CONVERSION_ACTION_SOURCES, + CUSTOM_CONVERSION_EVENT_TYPES, + PIXEL_DATA_USE_SETTINGS, PLATFORMS, POST_STATUSES, AccountGroup, @@ -57,6 +60,8 @@ AdInsights, AdInsightsReport, AdLibraryPage, + AdPixel, + AdPixelDetail, AdProvider, AdSet, AdSetNode, @@ -101,22 +106,12 @@ ContentSignal, ConversationAnalytics, ConversationAnalyticsRow, + ConversionBatch, ConversionMetrics, ConversionRule, ConversionUploadResult, - HashedConversionEvent, - ConversionBatch, - AdPixel, - AdPixelDetail, - PixelEventCount, - PixelStats, - MatchKeyCoverage, - EventMatchQuality, - CustomConversionRule, CustomConversion, - CONVERSION_ACTION_SOURCES, - CUSTOM_CONVERSION_EVENT_TYPES, - PIXEL_DATA_USE_SETTINGS, + CustomConversionRule, DecayBand, Delivery, DiscordChannel, @@ -127,6 +122,7 @@ DiscordRole, DiscordScheduledEvent, Enrollment, + EventMatchQuality, ExternalAd, FeedLead, FrequencyBand, @@ -148,6 +144,7 @@ GoogleRecommendationImpact, GoogleSearchTerm, GoogleSharedSet, + HashedConversionEvent, InboxAccount, InboxAccountRef, InboxApproval, @@ -180,6 +177,7 @@ LeadsFeedPage, LeadsPage, LinkedInMention, + MatchKeyCoverage, MediaItem, MetaGreeting, MetaGreetingText, @@ -196,6 +194,8 @@ Page, PageMeta, PinterestBoard, + PixelEventCount, + PixelStats, Platform, PlatformMetricRow, PlatformMetricsBlock, diff --git a/src/fopost/resources/ads.py b/src/fopost/resources/ads.py index b272467..453f2ab 100644 --- a/src/fopost/resources/ads.py +++ b/src/fopost/resources/ads.py @@ -28,6 +28,8 @@ AdInsightsReport, AdLabel, AdLibraryPage, + AdPixel, + AdPixelDetail, AdProvider, AdSet, AdSource, @@ -39,8 +41,12 @@ BulkAdStatusResult, CatalogBatchResult, CatalogProductsPage, + ConversionBatch, ConversionMetrics, ConversionRule, + ConversionUploadResult, + CustomConversion, + EventMatchQuality, ExternalAd, HighDemandPeriod, IosCampaignLimits, @@ -52,6 +58,7 @@ LeadsPage, NetworkAd, PartnershipCreator, + PixelStats, ProductCatalog, ProductCatalogsResult, ProductFeed, @@ -1964,21 +1971,326 @@ def upload_conversions( ad_account_id: str, pixel_id: str, events: Sequence[Mapping[str, Any]], - ) -> dict[str, Any]: - """Offline conversions. Identifiers are hashed before they leave FoPost.""" - result = unwrap( - self._http.post( - "/ads/conversions", - { - "workspaceId": workspace_id, - "connectionId": connection_id, - "adAccountId": ad_account_id, - "pixelId": pixel_id, - "events": [dict(e) for e in events], - }, + idempotency_key: str | None = None, + ) -> ConversionUploadResult: + """Offline conversions. Identifiers are hashed before they leave FoPost. + + Each event is the API's own shape: ``eventName``, ``occurredAt`` and optionally + ``eventId``, ``actionSource``, ``sourceUrl``, ``email``, ``phone``, ``externalId``, + ``clientIpAddress``, ``clientUserAgent``, ``clickId``, ``browserId``, ``contentIds``, + ``valueMinor``, ``currency``, ``orderId``. + """ + return self._send_conversions( + "/ads/conversions", + workspace_id, + connection_id, + ad_account_id, + pixel_id, + events, + idempotency_key, + ) + + def send_conversions( + self, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + pixel_id: str, + events: Sequence[Mapping[str, Any]], + idempotency_key: str, + ) -> ConversionUploadResult: + """The Conversions API send. ``idempotency_key`` is required: a reused key + answers ``replayed=True`` and nothing reaches the network again.""" + return self._send_conversions( + "/ads/conversions/events", + workspace_id, + connection_id, + ad_account_id, + pixel_id, + events, + idempotency_key, + ) + + def _send_conversions( + self, + path: str, + workspace_id: str, + connection_id: str, + ad_account_id: str, + pixel_id: str, + events: Sequence[Mapping[str, Any]], + idempotency_key: str | None, + ) -> ConversionUploadResult: + headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None + return ConversionUploadResult.model_validate( + unwrap( + self._http.post( + path, + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "pixelId": pixel_id, + "events": [dict(e) for e in events], + }, + headers=headers, + ) ) ) - return result if isinstance(result, dict) else {"data": result} + + def conversion_batches( + self, + *, + workspace_id: str | None = None, + connection_id: str | None = None, + limit: int | None = None, + ) -> builtins.list[ConversionBatch]: + """Stored batches, newest first, with hashed identifiers only.""" + return parse_list( + ConversionBatch, + unwrap( + self._http.get( + "/ads/conversions/events", + { + "workspace_id": workspace_id, + "connection_id": connection_id, + "limit": limit, + }, + ) + ), + ) + + # ─── Pixels, match quality and custom conversions ─────────────────── + + def pixels( + self, *, connection_id: str, ad_account_id: str, workspace_id: str | None = None + ) -> builtins.list[AdPixel]: + return parse_list( + AdPixel, + unwrap( + self._http.get( + "/ads/pixels", self._account_params(workspace_id, connection_id, ad_account_id) + ) + ), + ) + + def create_pixel( + self, *, workspace_id: str, connection_id: str, ad_account_id: str, name: str + ) -> AdPixelDetail: + """``code`` on the answer is the script to place on the site.""" + return AdPixelDetail.model_validate( + unwrap( + self._http.post( + "/ads/pixels", + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "name": name, + }, + ) + ) + ) + + def get_pixel( + self, + pixel_id: str, + *, + connection_id: str, + ad_account_id: str, + workspace_id: str | None = None, + ) -> AdPixelDetail: + return AdPixelDetail.model_validate( + unwrap( + self._http.get( + f"/ads/pixels/{pixel_id}", + self._account_params(workspace_id, connection_id, ad_account_id), + ) + ) + ) + + def update_pixel( + self, + pixel_id: str, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + name: str | None = None, + data_use_setting: str | None = None, + ) -> AdPixelDetail: + """``data_use_setting`` is one of ``PIXEL_DATA_USE_SETTINGS``.""" + body = drop_unset( + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "name": name if name is not None else UNSET, + "dataUseSetting": data_use_setting if data_use_setting is not None else UNSET, + } + ) + return AdPixelDetail.model_validate( + unwrap(self._http.patch(f"/ads/pixels/{pixel_id}", body)) + ) + + def share_pixel( + self, + pixel_id: str, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + target_ad_account_id: str, + ) -> None: + """Share the pixel with another ad account under the same business portfolio.""" + self._http.post( + f"/ads/pixels/{pixel_id}/share", + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "targetAdAccountId": target_ad_account_id, + }, + ) + + def pixel_stats( + self, + pixel_id: str, + *, + connection_id: str, + ad_account_id: str, + workspace_id: str | None = None, + ) -> PixelStats: + return PixelStats.model_validate( + unwrap( + self._http.get( + f"/ads/pixels/{pixel_id}/stats", + self._account_params(workspace_id, connection_id, ad_account_id), + ) + ) + ) + + def event_match_quality( + self, + pixel_id: str, + *, + connection_id: str, + ad_account_id: str, + workspace_id: str | None = None, + ) -> builtins.list[EventMatchQuality]: + return parse_list( + EventMatchQuality, + unwrap( + self._http.get( + f"/ads/pixels/{pixel_id}/match-quality", + self._account_params(workspace_id, connection_id, ad_account_id), + ) + ), + ) + + def custom_conversions( + self, *, connection_id: str, ad_account_id: str, workspace_id: str | None = None + ) -> builtins.list[CustomConversion]: + return parse_list( + CustomConversion, + unwrap( + self._http.get( + "/ads/custom-conversions", + self._account_params(workspace_id, connection_id, ad_account_id), + ) + ), + ) + + def create_custom_conversion( + self, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + pixel_id: str, + name: str, + event_type: str, + rule: Mapping[str, Any], + description: str | None = None, + default_value_minor: int | None = None, + ) -> CustomConversion: + """``event_type`` is one of ``CUSTOM_CONVERSION_EVENT_TYPES``; ``rule`` carries + ``eventName`` and one of ``urlContains`` or ``urlEquals``.""" + body = drop_unset( + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "pixelId": pixel_id, + "name": name, + "eventType": event_type, + "rule": dict(rule), + "description": description if description is not None else UNSET, + "defaultValueMinor": default_value_minor + if default_value_minor is not None + else UNSET, + } + ) + return CustomConversion.model_validate( + unwrap(self._http.post("/ads/custom-conversions", body)) + ) + + def get_custom_conversion( + self, + conversion_id: str, + *, + connection_id: str, + ad_account_id: str, + workspace_id: str | None = None, + ) -> CustomConversion: + return CustomConversion.model_validate( + unwrap( + self._http.get( + f"/ads/custom-conversions/{conversion_id}", + self._account_params(workspace_id, connection_id, ad_account_id), + ) + ) + ) + + def update_custom_conversion( + self, + conversion_id: str, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + name: str | None = None, + description: str | None = None, + default_value_minor: int | None = None, + ) -> CustomConversion: + body = drop_unset( + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "name": name if name is not None else UNSET, + "description": description if description is not None else UNSET, + "defaultValueMinor": default_value_minor + if default_value_minor is not None + else UNSET, + } + ) + return CustomConversion.model_validate( + unwrap(self._http.patch(f"/ads/custom-conversions/{conversion_id}", body)) + ) + + def delete_custom_conversion( + self, conversion_id: str, *, workspace_id: str, connection_id: str, ad_account_id: str + ) -> None: + self._http.delete( + f"/ads/custom-conversions/{conversion_id}", + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + }, + ) def comments( self, From fe549b8d88976af4abe6c5a765d8bb683d3b63cd Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:44:43 +0200 Subject: [PATCH 4/5] test(ads): idempotent conversion send, pixel read, custom conversion create --- tests/test_ads_tiktok.py | 9 ++- tests/test_ads_tracking.py | 121 +++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/test_ads_tracking.py diff --git a/tests/test_ads_tiktok.py b/tests/test_ads_tiktok.py index b00e6bf..a557351 100644 --- a/tests/test_ads_tiktok.py +++ b/tests/test_ads_tiktok.py @@ -85,7 +85,10 @@ def test_spark_post_id_and_smart_plus_travel_in_the_body(client: Fopost) -> None @respx.mock def test_conversions_hash_nothing_locally_and_report_what_was_accepted(client: Fopost) -> None: route = respx.post(f"{BASE_URL}/ads/conversions").mock( - return_value=httpx.Response(202, json={"data": {"accepted": 1}}) + return_value=httpx.Response( + 202, + json={"data": {"id": "cb_1", "accepted": 1, "eventCount": 1, "replayed": False}}, + ) ) result = client.ads.upload_conversions( @@ -96,9 +99,11 @@ def test_conversions_hash_nothing_locally_and_report_what_was_accepted(client: F events=[{"eventName": "CompletePayment", "occurredAt": "2026-09-18T10:04:00Z"}], ) - assert result["accepted"] == 1 + assert result.accepted == 1 + assert result.replayed is False body = json.loads(route.calls.last.request.content) assert body["pixelId"] == "px_1" + assert "idempotency-key" not in route.calls.last.request.headers assert body["events"][0]["eventName"] == "CompletePayment" diff --git a/tests/test_ads_tracking.py b/tests/test_ads_tracking.py new file mode 100644 index 0000000..b2b5b37 --- /dev/null +++ b/tests/test_ads_tracking.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +SCOPE = {"workspace_id": "ws_1", "connection_id": "conn_1", "ad_account_id": "act_1"} + + +@respx.mock +def test_send_conversions_carries_the_idempotency_key_and_reports_a_replay( + client: Fopost, +) -> None: + route = respx.post(f"{BASE_URL}/ads/conversions/events").mock( + return_value=httpx.Response( + 202, + json={"data": {"id": "cb_1", "accepted": 0, "eventCount": 1, "replayed": True}}, + headers={"Idempotency-Replayed": "true"}, + ) + ) + + result = client.ads.send_conversions( + pixel_id="px_1", + events=[ + { + "eventName": "Purchase", + "occurredAt": "2026-10-18T10:04:00Z", + "eventId": "order-42", + "actionSource": "website", + "email": "someone@example.com", + "valueMinor": 1999, + "currency": "USD", + } + ], + idempotency_key="order-42", + **SCOPE, + ) + + assert result.replayed is True + assert result.event_count == 1 + request = route.calls.last.request + assert request.headers["idempotency-key"] == "order-42" + assert request.headers["x-api-key"] == "osk_test_key" + body = json.loads(request.content) + assert body["pixelId"] == "px_1" + assert body["events"][0]["eventId"] == "order-42" + + +@respx.mock +def test_pixel_detail_reads_the_install_code(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/ads/pixels/px_1").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "id": "px_1", + "name": "Site", + "code": "", + "createdAt": "2026-10-01T00:00:00Z", + "lastFiredAt": None, + "isUnavailable": False, + "dataUseSetting": "advertising_and_analytics", + } + }, + ) + ) + + pixel = client.ads.get_pixel("px_1", **SCOPE) + + assert pixel.code == "" + assert pixel.data_use_setting == "advertising_and_analytics" + assert pixel.is_unavailable is False + params = route.calls.last.request.url.params + assert params["connection_id"] == "conn_1" + assert params["ad_account_id"] == "act_1" + + +@respx.mock +def test_create_custom_conversion_sends_the_rule_and_parses_it_back(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/ads/custom-conversions").mock( + return_value=httpx.Response( + 201, + json={ + "data": { + "id": "cc_1", + "name": "Checkout", + "pixelId": "px_1", + "eventType": "PURCHASE", + "rule": {"eventName": "PageView", "urlContains": "/thank-you"}, + "description": None, + "defaultValueMinor": 1999, + "isArchived": False, + "isUnavailable": False, + "createdAt": None, + "lastFiredAt": None, + } + }, + ) + ) + + conversion = client.ads.create_custom_conversion( + pixel_id="px_1", + name="Checkout", + event_type="PURCHASE", + rule={"eventName": "PageView", "urlContains": "/thank-you"}, + default_value_minor=1999, + **SCOPE, + ) + + assert conversion.id == "cc_1" + assert conversion.rule is not None + assert conversion.rule.url_contains == "/thank-you" + body = json.loads(route.calls.last.request.content) + assert body["eventType"] == "PURCHASE" + assert body["rule"] == {"eventName": "PageView", "urlContains": "/thank-you"} + assert body["defaultValueMinor"] == 1999 + assert "description" not in body From 5e4211bacb64438ae201fe1973993f6c75e370da Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:44:43 +0200 Subject: [PATCH 5/5] docs: ads tracking methods in the README and changelog --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ README.md | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e7349..c82fa60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to `fopost` are documented here. +## Unreleased + +### Added + +- Ads tracking (`ads` scope): `ads.send_conversions` is the Conversions API send with a + required `idempotency_key` sent as the `Idempotency-Key` header (a reused key answers + `replayed=True`), `ads.conversion_batches` lists stored batches with hashed identifiers, + and `ads.upload_conversions` takes an optional `idempotency_key`. Events accept + `eventId`, `actionSource`, `sourceUrl`, `externalId`, `clientIpAddress`, + `clientUserAgent`, `clickId`, `browserId` and `contentIds`. +- Pixels: `ads.pixels`, `create_pixel` (the answer carries the install `code`), + `get_pixel`, `update_pixel`, `share_pixel`, `pixel_stats` (last 28 days) and + `event_match_quality`. +- Custom conversions: `ads.custom_conversions`, `create_custom_conversion`, + `get_custom_conversion`, `update_custom_conversion`, `delete_custom_conversion`. +- Models `ConversionUploadResult`, `ConversionBatch`, `HashedConversionEvent`, `AdPixel`, + `AdPixelDetail`, `PixelStats`, `PixelEventCount`, `EventMatchQuality`, + `MatchKeyCoverage`, `CustomConversion`, `CustomConversionRule`, and the tuples + `CONVERSION_ACTION_SOURCES`, `CUSTOM_CONVERSION_EVENT_TYPES`, `PIXEL_DATA_USE_SETTINGS`. + +### Changed + +- `ads.upload_conversions` returns a `ConversionUploadResult` (`id`, `accepted`, + `event_count`, `replayed`) instead of a dict; read `result.accepted` rather than + `result["accepted"]`. + ## 0.3.0 - 2026-10-18 ### Removed diff --git a/README.md b/README.md index 433739f..d4513c5 100644 --- a/README.md +++ b/README.md @@ -324,9 +324,9 @@ except FopostError as err: | `contacts` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `import_csv`, `list_fields`, `create_field`, `update_field`, `delete_field`, `conversation_analytics` | | `broadcasts` | `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`, `recipients` | | `sequences` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` | -| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `goals`, `catalogs`, `create_catalog`, `get_catalog`, `update_catalog`, `delete_catalog`, `catalog_products`, `write_catalog_products`, `product_feeds`, `create_product_feed`, `delete_product_feed`, `feed_uploads`, `start_feed_upload`, `product_sets`, `create_product_set`, `update_product_set`, `delete_product_set`, `reach_frequency`, `create_reach_frequency`, `get_reach_frequency`, `reserve_reach_frequency`, `cancel_reach_frequency`, `library`, `partnership_creators`, `request_partnership`, `revoke_partnership`, `account_activity`, `labels`, `create_label`, `update_label`, `delete_label`, `apply_label`, `studies`, `create_study`, `get_study`, `delete_study`, `ios_campaign_limits`, `high_demand_periods`, `create_high_demand_period`, `delete_high_demand_period`, `value_rule_sets`, `create_value_rule_set`, `delete_value_rule_set`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` | +| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `goals`, `catalogs`, `create_catalog`, `get_catalog`, `update_catalog`, `delete_catalog`, `catalog_products`, `write_catalog_products`, `product_feeds`, `create_product_feed`, `delete_product_feed`, `feed_uploads`, `start_feed_upload`, `product_sets`, `create_product_set`, `update_product_set`, `delete_product_set`, `reach_frequency`, `create_reach_frequency`, `get_reach_frequency`, `reserve_reach_frequency`, `cancel_reach_frequency`, `library`, `partnership_creators`, `request_partnership`, `revoke_partnership`, `account_activity`, `labels`, `create_label`, `update_label`, `delete_label`, `apply_label`, `studies`, `create_study`, `get_study`, `delete_study`, `ios_campaign_limits`, `high_demand_periods`, `create_high_demand_period`, `delete_high_demand_period`, `value_rule_sets`, `create_value_rule_set`, `delete_value_rule_set`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events`, `upload_conversions`, `send_conversions`, `conversion_batches`, `pixels`, `create_pixel`, `get_pixel`, `update_pixel`, `share_pixel`, `pixel_stats`, `event_match_quality`, `custom_conversions`, `create_custom_conversion`, `get_custom_conversion`, `update_custom_conversion`, `delete_custom_conversion` | | `knowledge` | `list`, `create`, `update`, `delete`, `sync`, `search` | -| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` | +| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events`, `upload_conversions`, `send_conversions`, `conversion_batches`, `pixels`, `create_pixel`, `get_pixel`, `update_pixel`, `share_pixel`, `pixel_stats`, `event_match_quality`, `custom_conversions`, `create_custom_conversion`, `get_custom_conversion`, `update_custom_conversion`, `delete_custom_conversion` | | `google_business` | `get_location`, `update_location`, `get_attributes`, `update_attributes`, `get_menus`, `replace_menus`, `get_services`, `replace_services`, `list_media`, `add_media`, `delete_media`, `list_place_actions`, `create_place_action`, `update_place_action`, `delete_place_action`, `get_verification_options`, `start_verification`, `complete_verification`, `get_performance`, `get_search_keywords`, `assign` | | `blog` | `get_settings`, `update_settings`, `rotate_website_secret`, `test_website`, `list_keywords`, `suggest_seeds`, `plan_keywords`, `add_keyword`, `update_keyword`, `delete_keyword`, `list_articles`, `request_article`, `get_article`, `update_article`, `regenerate_article`, `submit_article`, `publish_article`, `reject_article`, `delete_article` | | `blogs` | `list_blogs`, `list_articles`, `get_article`, `create_article`, `update_article`, `delete_article`, `list_products`, `update_product` |