diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc7bb73..7e9e0a4 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.4.0 - 2026-10-18
### Added
diff --git a/README.md b/README.md
index 8c6cfe1..32442ee 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` |
diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py
index a9c24a5..17c14e8 100644
--- a/src/fopost/__init__.py
+++ b/src/fopost/__init__.py
@@ -37,6 +37,9 @@
BLOG_KEYWORD_STATUSES,
BLOG_TARGET_KINDS,
CONNECT_SESSION_STATUSES,
+ CONVERSION_ACTION_SOURCES,
+ CUSTOM_CONVERSION_EVENT_TYPES,
+ PIXEL_DATA_USE_SETTINGS,
PLATFORMS,
POST_STATUSES,
WEBHOOK_EVENTS,
@@ -63,6 +66,8 @@
AdInsights,
AdInsightsReport,
AdLibraryPage,
+ AdPixel,
+ AdPixelDetail,
AdProvider,
AdSet,
AdSetNode,
@@ -142,8 +147,12 @@
ContentSignal,
ConversationAnalytics,
ConversationAnalyticsRow,
+ ConversionBatch,
ConversionMetrics,
ConversionRule,
+ ConversionUploadResult,
+ CustomConversion,
+ CustomConversionRule,
DecayBand,
Delivery,
DemographicAccountRef,
@@ -157,6 +166,7 @@
DiscordRole,
DiscordScheduledEvent,
Enrollment,
+ EventMatchQuality,
ExternalAd,
FeedLead,
FollowerHistoryPoint,
@@ -179,6 +189,7 @@
GoogleRecommendationImpact,
GoogleSearchTerm,
GoogleSharedSet,
+ HashedConversionEvent,
InboxAccount,
InboxAccountRef,
InboxAnalytics,
@@ -221,6 +232,7 @@
LeadsFeedPage,
LeadsPage,
LinkedInMention,
+ MatchKeyCoverage,
MediaItem,
MetaGreeting,
MetaGreetingText,
@@ -237,6 +249,8 @@
Page,
PageMeta,
PinterestBoard,
+ PixelEventCount,
+ PixelStats,
Platform,
PlatformMetricRow,
PlatformMetricsBlock,
@@ -572,6 +586,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/_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)
diff --git a/src/fopost/models.py b/src/fopost/models.py
index dbb35c6..903f67e 100644
--- a/src/fopost/models.py
+++ b/src/fopost/models.py
@@ -1779,6 +1779,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
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,
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