diff --git a/README.md b/README.md
index e14d6f8..aed7dda 100644
--- a/README.md
+++ b/README.md
@@ -114,7 +114,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| `knowledge()` | `list`, `create`, `createText`, `createUrl`, `createFile`, `update`, `delete`, `sync`, `search` |
| `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `goals`, `catalogs`, `createCatalog`, `catalog`, `updateCatalog`, `deleteCatalog`, `catalogProducts`, `writeCatalogProducts`, `productFeeds`, `createProductFeed`, `deleteProductFeed`, `feedUploads`, `startFeedUpload`, `productSets`, `createProductSet`, `updateProductSet`, `deleteProductSet`, `reachFrequency`, `createReachFrequency`, `reachFrequencyPrediction`, `reserveReachFrequency`, `cancelReachFrequency`, `library`, `partnershipCreators`, `requestPartnership`, `revokePartnership`, `accountActivity`, `labels`, `createLabel`, `updateLabel`, `deleteLabel`, `applyLabel`, `studies`, `createStudy`, `study`, `deleteStudy`, `iosCampaignLimits`, `highDemandPeriods`, `createHighDemandPeriod`, `deleteHighDemandPeriod`, `valueRuleSets`, `createValueRuleSet`, `deleteValueRuleSet` |
| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` |
-| `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage` |
+| `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `addAudienceCompanies`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `bidPricing`, `supplyForecast`, `conversionRules`, `createConversionRule`, `conversionRule`, `updateConversionRule`, `deleteConversionRule`, `attachConversionRule`, `detachConversionRule`, `conversionMetrics`, `sendConversionEvents` |
| `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` |
| `validate()` | `post`, `length`, `media` |
| `activity()` | `list` |
diff --git a/src/main/java/com/fopost/sdk/model/AdProvider.java b/src/main/java/com/fopost/sdk/model/AdProvider.java
new file mode 100644
index 0000000..0e41ec7
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/AdProvider.java
@@ -0,0 +1,21 @@
+package com.fopost.sdk.model;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An ad network from the API's registry. {@code configured} false cannot be connected yet.
+ *
+ *
{@code capabilities} says what the network supports — campaigns, audiences, conversions,
+ * forecasts, adLibrary and so on. {@code targetingFacets} is what {@code searchTargeting} accepts
+ * here, and {@code trackingMacros} what the network expands in a creative's tracking parameters.
+ */
+public record AdProvider(
+ String id,
+ String name,
+ String logo,
+ Boolean configured,
+ List connectMethods,
+ Map capabilities,
+ List targetingFacets,
+ List trackingMacros) {}
diff --git a/src/main/java/com/fopost/sdk/model/AdTrackingMacro.java b/src/main/java/com/fopost/sdk/model/AdTrackingMacro.java
new file mode 100644
index 0000000..f22e873
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/AdTrackingMacro.java
@@ -0,0 +1,4 @@
+package com.fopost.sdk.model;
+
+/** A token a network expands in a link's tracking parameters at delivery time. */
+public record AdTrackingMacro(String token, String description) {}
diff --git a/src/main/java/com/fopost/sdk/model/BidPricing.java b/src/main/java/com/fopost/sdk/model/BidPricing.java
new file mode 100644
index 0000000..053d4c5
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BidPricing.java
@@ -0,0 +1,9 @@
+package com.fopost.sdk.model;
+
+/** What the auction costs, in minor units of the ad account currency. */
+public record BidPricing(
+ String currency,
+ Long suggestedBidMinor,
+ Long minBidMinor,
+ Long maxBidMinor,
+ Long dailyBudgetFloorMinor) {}
diff --git a/src/main/java/com/fopost/sdk/model/ConversionMetrics.java b/src/main/java/com/fopost/sdk/model/ConversionMetrics.java
new file mode 100644
index 0000000..d49061c
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/ConversionMetrics.java
@@ -0,0 +1,9 @@
+package com.fopost.sdk.model;
+
+/** What a conversion rule recorded over a date range. */
+public record ConversionMetrics(
+ Integer conversions,
+ Integer postClickConversions,
+ Integer viewThroughConversions,
+ Long valueMinor,
+ Long costPerConversionMinor) {}
diff --git a/src/main/java/com/fopost/sdk/model/ConversionRule.java b/src/main/java/com/fopost/sdk/model/ConversionRule.java
new file mode 100644
index 0000000..0f9ae6d
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/ConversionRule.java
@@ -0,0 +1,21 @@
+package com.fopost.sdk.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/**
+ * How the network attributes a sale or a sign-up back to an ad set. {@code campaignIds} are the ad
+ * sets this rule is attached to.
+ */
+public record ConversionRule(
+ String id,
+ String name,
+ @JsonProperty("type") String conversionType,
+ String attribution,
+ Integer postClickWindowDays,
+ Integer viewThroughWindowDays,
+ Long valueMinor,
+ String currency,
+ Boolean enabled,
+ String createdAt,
+ List campaignIds) {}
diff --git a/src/main/java/com/fopost/sdk/model/SupplyForecast.java b/src/main/java/com/fopost/sdk/model/SupplyForecast.java
new file mode 100644
index 0000000..39e24e3
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/SupplyForecast.java
@@ -0,0 +1,13 @@
+package com.fopost.sdk.model;
+
+/**
+ * What an audience would deliver at a budget, over the network's own window. {@code ready} is
+ * false while the network has no answer for that audience.
+ */
+public record SupplyForecast(
+ String currency,
+ Long impressions,
+ Long clicks,
+ Long spendMinor,
+ Long windowDays,
+ Boolean ready) {}
diff --git a/src/main/java/com/fopost/sdk/param/AdCompanyParams.java b/src/main/java/com/fopost/sdk/param/AdCompanyParams.java
new file mode 100644
index 0000000..8347bce
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/AdCompanyParams.java
@@ -0,0 +1,59 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * One row of a company-list upload. At least one of name, domain, page url or ticker is required;
+ * the rows travel with the request and are never stored.
+ */
+public final class AdCompanyParams {
+
+ private final Map body = new LinkedHashMap<>();
+
+ private AdCompanyParams() {}
+
+ public static AdCompanyParams named(String name) {
+ AdCompanyParams params = new AdCompanyParams();
+ params.body.put("name", name);
+ return params;
+ }
+
+ public static AdCompanyParams domain(String domain) {
+ AdCompanyParams params = new AdCompanyParams();
+ params.body.put("domain", domain);
+ return params;
+ }
+
+ /** The company's page on the network. */
+ public static AdCompanyParams pageUrl(String pageUrl) {
+ AdCompanyParams params = new AdCompanyParams();
+ params.body.put("pageUrl", pageUrl);
+ return params;
+ }
+
+ public AdCompanyParams withName(String name) {
+ body.put("name", name);
+ return this;
+ }
+
+ public AdCompanyParams withDomain(String domain) {
+ body.put("domain", domain);
+ return this;
+ }
+
+ /** Stock ticker, where the network matches on one. */
+ public AdCompanyParams withTicker(String ticker) {
+ body.put("ticker", ticker);
+ return this;
+ }
+
+ public AdCompanyParams withCountry(String country) {
+ body.put("country", country);
+ return this;
+ }
+
+ public Map toMap() {
+ return new LinkedHashMap<>(body);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/param/AdForecastParams.java b/src/main/java/com/fopost/sdk/param/AdForecastParams.java
new file mode 100644
index 0000000..fc7783a
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/AdForecastParams.java
@@ -0,0 +1,53 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The shared body of a bid-pricing or supply-forecast request. {@code bidType} applies to bid
+ * pricing only, {@code budgetMinor} to the supply forecast only.
+ */
+public final class AdForecastParams {
+
+ private final Map body = new LinkedHashMap<>();
+
+ private AdForecastParams() {}
+
+ /** {@code goal} is engagement, traffic, awareness or video_views. */
+ public static AdForecastParams of(
+ String workspaceId,
+ String connectionId,
+ String adAccountId,
+ String goal,
+ AdTargetingParams targeting) {
+ AdForecastParams params = new AdForecastParams();
+ params.body.put("workspaceId", workspaceId);
+ params.body.put("connectionId", connectionId);
+ params.body.put("adAccountId", adAccountId);
+ params.body.put("goal", goal);
+ params.body.put("targeting", targeting.toMap());
+ return params;
+ }
+
+ public AdForecastParams placements(List placements) {
+ body.put("placements", List.copyOf(placements));
+ return this;
+ }
+
+ /** CPC, CPM or CPV. Bid pricing only. */
+ public AdForecastParams bidType(String bidType) {
+ body.put("bidType", bidType);
+ return this;
+ }
+
+ /** The budget for the forecast window, minor units. Supply forecast only. */
+ public AdForecastParams budgetMinor(long budgetMinor) {
+ body.put("budgetMinor", budgetMinor);
+ return this;
+ }
+
+ public Map toMap() {
+ return new LinkedHashMap<>(body);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/param/ConversionEventParams.java b/src/main/java/com/fopost/sdk/param/ConversionEventParams.java
new file mode 100644
index 0000000..745bcba
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/ConversionEventParams.java
@@ -0,0 +1,49 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * One conversion sent back to the network. It needs an email or a click id; the address is hashed
+ * inside the API, so the network never receives it and nothing about an event is stored.
+ */
+public final class ConversionEventParams {
+
+ private final Map body = new LinkedHashMap<>();
+
+ private ConversionEventParams() {}
+
+ /** {@code happenedAt} is epoch milliseconds. */
+ public static ConversionEventParams at(long happenedAt) {
+ ConversionEventParams params = new ConversionEventParams();
+ params.body.put("happenedAt", happenedAt);
+ return params;
+ }
+
+ public ConversionEventParams email(String email) {
+ body.put("email", email);
+ return this;
+ }
+
+ /** The network's click id, as the landing page received it. */
+ public ConversionEventParams clickId(String clickId) {
+ body.put("clickId", clickId);
+ return this;
+ }
+
+ public ConversionEventParams value(long valueMinor, String currency) {
+ body.put("valueMinor", valueMinor);
+ body.put("currency", currency);
+ return this;
+ }
+
+ /** Your own id for the event, so a replay is counted once. */
+ public ConversionEventParams eventId(String eventId) {
+ body.put("eventId", eventId);
+ return this;
+ }
+
+ public Map toMap() {
+ return new LinkedHashMap<>(body);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/param/CreateConversionRuleParams.java b/src/main/java/com/fopost/sdk/param/CreateConversionRuleParams.java
new file mode 100644
index 0000000..b967e59
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/CreateConversionRuleParams.java
@@ -0,0 +1,54 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** A new conversion rule on one ad account. */
+public final class CreateConversionRuleParams {
+
+ private final Map body = new LinkedHashMap<>();
+
+ private CreateConversionRuleParams() {}
+
+ /**
+ * {@code type} is purchase, lead, sign_up, add_to_cart, download, install, key_page_view or
+ * other; {@code attribution} is last_touch or each_campaign.
+ */
+ public static CreateConversionRuleParams of(
+ String workspaceId,
+ String connectionId,
+ String adAccountId,
+ String name,
+ String type,
+ String attribution) {
+ CreateConversionRuleParams params = new CreateConversionRuleParams();
+ params.body.put("workspaceId", workspaceId);
+ params.body.put("connectionId", connectionId);
+ params.body.put("adAccountId", adAccountId);
+ params.body.put("name", name);
+ params.body.put("type", type);
+ params.body.put("attribution", attribution);
+ return params;
+ }
+
+ public CreateConversionRuleParams postClickWindowDays(int days) {
+ body.put("postClickWindowDays", days);
+ return this;
+ }
+
+ public CreateConversionRuleParams viewThroughWindowDays(int days) {
+ body.put("viewThroughWindowDays", days);
+ return this;
+ }
+
+ /** What one conversion is worth, minor units. */
+ public CreateConversionRuleParams value(long valueMinor, String currency) {
+ body.put("valueMinor", valueMinor);
+ body.put("currency", currency);
+ return this;
+ }
+
+ public Map toMap() {
+ return new LinkedHashMap<>(body);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/param/UpdateConversionRuleParams.java b/src/main/java/com/fopost/sdk/param/UpdateConversionRuleParams.java
new file mode 100644
index 0000000..8a63eb3
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/UpdateConversionRuleParams.java
@@ -0,0 +1,54 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Changes to a conversion rule. Only the fields you set move. */
+public final class UpdateConversionRuleParams {
+
+ private final Map body = new LinkedHashMap<>();
+
+ public static UpdateConversionRuleParams create() {
+ return new UpdateConversionRuleParams();
+ }
+
+ public UpdateConversionRuleParams name(String name) {
+ body.put("name", name);
+ return this;
+ }
+
+ public UpdateConversionRuleParams type(String type) {
+ body.put("type", type);
+ return this;
+ }
+
+ public UpdateConversionRuleParams attribution(String attribution) {
+ body.put("attribution", attribution);
+ return this;
+ }
+
+ public UpdateConversionRuleParams postClickWindowDays(int days) {
+ body.put("postClickWindowDays", days);
+ return this;
+ }
+
+ public UpdateConversionRuleParams viewThroughWindowDays(int days) {
+ body.put("viewThroughWindowDays", days);
+ return this;
+ }
+
+ public UpdateConversionRuleParams value(long valueMinor, String currency) {
+ body.put("valueMinor", valueMinor);
+ body.put("currency", currency);
+ return this;
+ }
+
+ public UpdateConversionRuleParams enabled(boolean enabled) {
+ body.put("enabled", enabled);
+ return this;
+ }
+
+ public Map toMap() {
+ return new LinkedHashMap<>(body);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/resource/AdsResource.java b/src/main/java/com/fopost/sdk/resource/AdsResource.java
index e5d3b36..cc1b2c9 100644
--- a/src/main/java/com/fopost/sdk/resource/AdsResource.java
+++ b/src/main/java/com/fopost/sdk/resource/AdsResource.java
@@ -4,7 +4,6 @@
import com.fopost.sdk.model.Ad;
import com.fopost.sdk.model.AdActivity;
import com.fopost.sdk.model.AdLabel;
-import com.fopost.sdk.model.AdLibraryPage;
import com.fopost.sdk.model.AdStudy;
import com.fopost.sdk.model.CatalogBatchResult;
import com.fopost.sdk.model.CatalogProductsPage;
@@ -39,12 +38,17 @@
import com.fopost.sdk.model.AdCreative;
import com.fopost.sdk.model.AdIdentity;
import com.fopost.sdk.model.AdInsightsReport;
+import com.fopost.sdk.model.AdLibraryPage;
+import com.fopost.sdk.model.AdProvider;
import com.fopost.sdk.model.AdSet;
import com.fopost.sdk.model.AdSource;
import com.fopost.sdk.model.Audience;
import com.fopost.sdk.model.AudiencesResult;
import com.fopost.sdk.model.BoostablePost;
+import com.fopost.sdk.model.BidPricing;
import com.fopost.sdk.model.BulkAdStatusResult;
+import com.fopost.sdk.model.ConversionMetrics;
+import com.fopost.sdk.model.ConversionRule;
import com.fopost.sdk.model.CreatedAudience;
import com.fopost.sdk.model.ExternalAd;
import com.fopost.sdk.model.LeadFormDetail;
@@ -56,7 +60,10 @@
import com.fopost.sdk.model.NetworkAd;
import com.fopost.sdk.model.ReachEstimate;
import com.fopost.sdk.model.SparkPost;
+import com.fopost.sdk.model.SupplyForecast;
import com.fopost.sdk.model.TargetingOption;
+import com.fopost.sdk.param.AdCompanyParams;
+import com.fopost.sdk.param.AdForecastParams;
import com.fopost.sdk.param.AdInsightsParams;
import com.fopost.sdk.param.BoostPostParams;
import com.fopost.sdk.param.BulkAdStatusParams;
@@ -64,7 +71,9 @@
import com.fopost.sdk.param.CreateAdCreativeParams;
import com.fopost.sdk.param.CreateAdParams;
import com.fopost.sdk.param.CreateAdSetParams;
+import com.fopost.sdk.param.ConversionEventParams;
import com.fopost.sdk.param.CreateAudienceParams;
+import com.fopost.sdk.param.CreateConversionRuleParams;
import com.fopost.sdk.param.CreateLeadFormParams;
import com.fopost.sdk.param.CreateNetworkAdParams;
import com.fopost.sdk.param.LeadsFeedParams;
@@ -73,6 +82,7 @@
import com.fopost.sdk.param.UpdateAdSetParams;
import com.fopost.sdk.param.UploadConversionsParams;
import com.fopost.sdk.param.UpdateAudienceParams;
+import com.fopost.sdk.param.UpdateConversionRuleParams;
import com.fopost.sdk.param.UpdateNetworkAdParams;
import java.util.LinkedHashMap;
import java.util.List;
@@ -160,16 +170,21 @@ public List sources(String workspaceId) {
// ─── Connections ──────────────────────────────────────────────────────────
- public String authorizeMeta(String workspaceId) {
- return authorizeMeta(workspaceId, null, null);
+ /** The ad networks this deployment knows, with what each one supports. */
+ public List providers() {
+ return http.convertList(ApiClient.unwrap(http.get("/v1/ads/providers", Map.of())), AdProvider.class);
+ }
+
+ public String authorize(String provider, String workspaceId) {
+ return authorize(provider, workspaceId, null, null);
}
/**
- * The login url for connecting a Meta Ads account. The user who calls this must finish the
- * login in their own browser session. {@code method} is business or user; {@code returnTo} is
- * the dashboard path to land on afterwards.
+ * The login url for connecting an ad network. The user who calls this must finish the login in
+ * their own browser session. {@code method} is one of the network's own connect methods;
+ * {@code returnTo} is the dashboard path to land on afterwards.
*/
- public String authorizeMeta(String workspaceId, String method, String returnTo) {
+ public String authorize(String provider, String workspaceId, String method, String returnTo) {
Map body = new LinkedHashMap<>();
body.put("workspaceId", workspaceId);
if (method != null) {
@@ -178,7 +193,21 @@ public String authorizeMeta(String workspaceId, String method, String returnTo)
if (returnTo != null) {
body.put("returnTo", returnTo);
}
- return ApiClient.unwrap(http.post("/v1/ads/connections/meta/authorize", body)).path("url").asText();
+ return ApiClient.unwrap(http.post("/v1/ads/connections/" + provider + "/authorize", body))
+ .path("url")
+ .asText();
+ }
+
+ /** @deprecated use {@link #authorize(String, String)} with the provider id meta. */
+ @Deprecated
+ public String authorizeMeta(String workspaceId) {
+ return authorize("meta", workspaceId, null, null);
+ }
+
+ /** @deprecated use {@link #authorize(String, String, String, String)}. */
+ @Deprecated
+ public String authorizeMeta(String workspaceId, String method, String returnTo) {
+ return authorize("meta", workspaceId, method, returnTo);
}
/** Also deletes every ad record FoPost created through the connection. */
@@ -494,6 +523,126 @@ public int addAudienceUsers(String audienceId, String workspaceId, String connec
.asInt();
}
+ /**
+ * Add companies to a company-list audience. Returns how many the network took. The rows travel
+ * with the request and are never stored.
+ */
+ public int addAudienceCompanies(
+ String audienceId, String workspaceId, String connectionId, List companies) {
+ Map body = new LinkedHashMap<>();
+ body.put("companies", companies.stream().map(AdCompanyParams::toMap).toList());
+ Map query = connectionQuery(workspaceId, connectionId);
+ return ApiClient.unwrap(http.post("/v1/ads/audiences/" + audienceId + "/companies", body, query))
+ .path("added")
+ .asInt();
+ }
+
+ // ─── Forecasts, conversions and the public ad library ─────────────────────
+
+ /** What the auction currently costs for that audience. */
+ public BidPricing bidPricing(AdForecastParams params) {
+ return http.convert(
+ ApiClient.unwrap(http.post("/v1/ads/linkedin/bid-pricing", params.toMap())), BidPricing.class);
+ }
+
+ /** What that audience would deliver at that budget. */
+ public SupplyForecast supplyForecast(AdForecastParams params) {
+ return http.convert(
+ ApiClient.unwrap(http.post("/v1/ads/linkedin/supply-forecast", params.toMap())),
+ SupplyForecast.class);
+ }
+
+ public List conversionRules(String workspaceId, String connectionId, String adAccountId) {
+ Map query = connectionQuery(workspaceId, connectionId);
+ query.put("ad_account_id", adAccountId);
+ return http.convertList(
+ ApiClient.unwrap(http.get("/v1/ads/linkedin/conversion-rules", query)), ConversionRule.class);
+ }
+
+ /** Returns the new rule's id. */
+ public String createConversionRule(CreateConversionRuleParams params) {
+ return ApiClient.unwrap(http.post("/v1/ads/linkedin/conversion-rules", params.toMap()))
+ .path("id")
+ .asText();
+ }
+
+ public ConversionRule conversionRule(String ruleId, String workspaceId, String connectionId) {
+ return http.convert(
+ ApiClient.unwrap(http.get(conversionRulePath(ruleId, ""), connectionQuery(workspaceId, connectionId))),
+ ConversionRule.class);
+ }
+
+ public ConversionRule updateConversionRule(
+ String ruleId, String workspaceId, String connectionId, UpdateConversionRuleParams params) {
+ return http.convert(
+ ApiClient.unwrap(http.request(
+ "PATCH",
+ conversionRulePath(ruleId, ""),
+ params.toMap(),
+ connectionQuery(workspaceId, connectionId))),
+ ConversionRule.class);
+ }
+
+ /** Turns the rule off; the network keeps the history. */
+ public void deleteConversionRule(String ruleId, String workspaceId, String connectionId) {
+ http.request("DELETE", conversionRulePath(ruleId, ""), null, connectionQuery(workspaceId, connectionId));
+ }
+
+ public ConversionRule attachConversionRule(
+ String ruleId, String workspaceId, String connectionId, String campaignId) {
+ return association("POST", ruleId, workspaceId, connectionId, campaignId);
+ }
+
+ public ConversionRule detachConversionRule(
+ String ruleId, String workspaceId, String connectionId, String campaignId) {
+ return association("DELETE", ruleId, workspaceId, connectionId, campaignId);
+ }
+
+ /** What the rule recorded between two YYYY-MM-DD days, inclusive. */
+ public ConversionMetrics conversionMetrics(
+ String ruleId, String workspaceId, String connectionId, String since, String until) {
+ Map query = connectionQuery(workspaceId, connectionId);
+ query.put("since", since);
+ query.put("until", until);
+ return http.convert(
+ ApiClient.unwrap(http.get(conversionRulePath(ruleId, "/metrics"), query)), ConversionMetrics.class);
+ }
+
+ /**
+ * Send conversions back to the network. Returns how many it took. Each event needs an email or
+ * a click id; the address is hashed inside the API and nothing about an event is stored.
+ */
+ public int sendConversionEvents(
+ String ruleId, String workspaceId, String connectionId, List events) {
+ Map body = new LinkedHashMap<>();
+ body.put("events", events.stream().map(ConversionEventParams::toMap).toList());
+ return ApiClient.unwrap(
+ http.post(
+ conversionRulePath(ruleId, "/events"),
+ body,
+ connectionQuery(workspaceId, connectionId)))
+ .path("accepted")
+ .asInt();
+ }
+
+
+ private ConversionRule association(
+ String method, String ruleId, String workspaceId, String connectionId, String campaignId) {
+ Map body = new LinkedHashMap<>();
+ body.put("campaignId", campaignId);
+ return http.convert(
+ ApiClient.unwrap(http.request(
+ method,
+ conversionRulePath(ruleId, "/associations"),
+ body,
+ connectionQuery(workspaceId, connectionId))),
+ ConversionRule.class);
+ }
+
+ private static String conversionRulePath(String ruleId, String suffix) {
+ return "/v1/ads/linkedin/conversion-rules/" + ruleId + suffix;
+ }
+
public List searchTargeting(String connectionId, String type, String q) {
return searchTargeting(connectionId, type, q, null);
}
diff --git a/src/test/java/com/fopost/sdk/AdsNetworksTest.java b/src/test/java/com/fopost/sdk/AdsNetworksTest.java
new file mode 100644
index 0000000..c279951
--- /dev/null
+++ b/src/test/java/com/fopost/sdk/AdsNetworksTest.java
@@ -0,0 +1,81 @@
+package com.fopost.sdk;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fopost.sdk.model.AdProvider;
+import com.fopost.sdk.param.AdCompanyParams;
+import com.fopost.sdk.param.ConversionEventParams;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class AdsNetworksTest {
+
+ @Test
+ void authorizeReachesWhicheverNetworkTheRegistryNamed() {
+ FakeTransport transport =
+ new FakeTransport().enqueue(200, "{\"data\":{\"url\":\"https://www.linkedin.com/oauth\"}}");
+
+ String url = TestSupport.client(transport).ads().authorize("linkedin", "w1", null, "/ads");
+
+ assertEquals("https://api.fopost.test/v1/ads/connections/linkedin/authorize", transport.last().url());
+ assertEquals("{\"workspaceId\":\"w1\",\"returnTo\":\"/ads\"}", transport.lastBody());
+ assertEquals("https://www.linkedin.com/oauth", url);
+ }
+
+ @Test
+ void providersCarryWhatEachNetworkSupports() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, """
+ {"data":[{"id":"linkedin","name":"LinkedIn Ads","logo":"linkedin","configured":false,
+ "connectMethods":[],"capabilities":{"conversions":true},
+ "targetingFacets":["country","job_title"],
+ "trackingMacros":[{"token":"{{LINKEDIN_CAMPAIGN_ID}}","description":"Campaign"}]}]}""");
+
+ List providers = TestSupport.client(transport).ads().providers();
+
+ assertEquals(1, providers.size());
+ assertFalse(providers.get(0).configured());
+ assertTrue(providers.get(0).capabilities().get("conversions"));
+ assertEquals(List.of("country", "job_title"), providers.get(0).targetingFacets());
+ assertEquals("{{LINKEDIN_CAMPAIGN_ID}}", providers.get(0).trackingMacros().get(0).token());
+ }
+
+ @Test
+ void companyRowsTravelWithTheRequest() {
+ FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{\"added\":2}}");
+
+ int added = TestSupport.client(transport)
+ .ads()
+ .addAudienceCompanies(
+ "urn:li:adSegment:44",
+ "w1",
+ "c1",
+ List.of(AdCompanyParams.domain("northwind.example"), AdCompanyParams.named("Contoso")));
+
+ assertEquals(2, added);
+ assertEquals(
+ "{\"companies\":[{\"domain\":\"northwind.example\"},{\"name\":\"Contoso\"}]}",
+ transport.lastBody());
+ }
+
+ @Test
+ void conversionEventsSendTheIdentityTheApiHashes() {
+ FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{\"accepted\":1}}");
+
+ int accepted = TestSupport.client(transport)
+ .ads()
+ .sendConversionEvents(
+ "urn:li:conversion:9",
+ "w1",
+ "c1",
+ List.of(ConversionEventParams.at(1758326400000L).email("buyer@example.test")));
+
+ assertEquals(1, accepted);
+ assertTrue(transport
+ .last()
+ .url()
+ .startsWith("https://api.fopost.test/v1/ads/linkedin/conversion-rules/urn:li:conversion:9/events"));
+ }
+}