From 4b1c3a2367207ee3662d491ae05e40f330958617 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:55:55 +0200 Subject: [PATCH] feat(ads): TikTok identities, Spark ads, conversions and ad comments The ads resource gains tiktokBusinessCenters, tiktokIdentities, sparkPosts, uploadConversions and the four ad-comment calls, plus sparkPostId on an ad and smartPlus on a campaign. --- .../fopost/sdk/model/AdBusinessCenter.java | 4 + .../java/com/fopost/sdk/model/AdComment.java | 17 +++ .../com/fopost/sdk/model/AdCommentsPage.java | 6 + .../java/com/fopost/sdk/model/AdIdentity.java | 8 ++ .../java/com/fopost/sdk/model/SparkPost.java | 5 + .../sdk/param/CreateAdCampaignParams.java | 9 ++ .../com/fopost/sdk/param/CreateAdParams.java | 11 ++ .../sdk/param/UploadConversionsParams.java | 53 ++++++++ .../com/fopost/sdk/resource/AdsResource.java | 106 ++++++++++++++++ .../java/com/fopost/sdk/AdsTikTokTest.java | 117 ++++++++++++++++++ 10 files changed, 336 insertions(+) create mode 100644 src/main/java/com/fopost/sdk/model/AdBusinessCenter.java create mode 100644 src/main/java/com/fopost/sdk/model/AdComment.java create mode 100644 src/main/java/com/fopost/sdk/model/AdCommentsPage.java create mode 100644 src/main/java/com/fopost/sdk/model/AdIdentity.java create mode 100644 src/main/java/com/fopost/sdk/model/SparkPost.java create mode 100644 src/main/java/com/fopost/sdk/param/UploadConversionsParams.java create mode 100644 src/test/java/com/fopost/sdk/AdsTikTokTest.java diff --git a/src/main/java/com/fopost/sdk/model/AdBusinessCenter.java b/src/main/java/com/fopost/sdk/model/AdBusinessCenter.java new file mode 100644 index 0000000..5e62b1b --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdBusinessCenter.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** A Business Center, or the network's equivalent grouping of ad accounts. */ +public record AdBusinessCenter(String id, String name, String role) {} diff --git a/src/main/java/com/fopost/sdk/model/AdComment.java b/src/main/java/com/fopost/sdk/model/AdComment.java new file mode 100644 index 0000000..9fb180a --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdComment.java @@ -0,0 +1,17 @@ +package com.fopost.sdk.model; + +/** + * A comment on an ad, read live from the network and never stored. + * {@code parentId} is the comment this one answers, when it is not on the ad itself. + */ +public record AdComment( + String id, + String adId, + String text, + String authorName, + String authorAvatarUrl, + String createdAt, + long likes, + long replyCount, + boolean hidden, + String parentId) {} diff --git a/src/main/java/com/fopost/sdk/model/AdCommentsPage.java b/src/main/java/com/fopost/sdk/model/AdCommentsPage.java new file mode 100644 index 0000000..457674d --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdCommentsPage.java @@ -0,0 +1,6 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** One page of an ad's comments. Pass {@code nextCursor} back as {@code after}; null means the end. */ +public record AdCommentsPage(List comments, String nextCursor) {} diff --git a/src/main/java/com/fopost/sdk/model/AdIdentity.java b/src/main/java/com/fopost/sdk/model/AdIdentity.java new file mode 100644 index 0000000..11f7bc8 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdIdentity.java @@ -0,0 +1,8 @@ +package com.fopost.sdk.model; + +/** + * The account an ad runs as. Meta calls it a Page, TikTok an identity; an + * identity id is what every route calls a {@code pageId}. {@code type} is the + * network's own identity kind, e.g. {@code CUSTOMIZED_USER}. + */ +public record AdIdentity(String id, String type, String name, String avatarUrl) {} diff --git a/src/main/java/com/fopost/sdk/model/SparkPost.java b/src/main/java/com/fopost/sdk/model/SparkPost.java new file mode 100644 index 0000000..7d00e64 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/SparkPost.java @@ -0,0 +1,5 @@ +package com.fopost.sdk.model; + +/** A post already live on the network, offered as the source of a Spark ad. */ +public record SparkPost( + String id, String identityId, String caption, String thumbnailUrl, String createdAt, Long views) {} diff --git a/src/main/java/com/fopost/sdk/param/CreateAdCampaignParams.java b/src/main/java/com/fopost/sdk/param/CreateAdCampaignParams.java index 3319832..a9a201d 100644 --- a/src/main/java/com/fopost/sdk/param/CreateAdCampaignParams.java +++ b/src/main/java/com/fopost/sdk/param/CreateAdCampaignParams.java @@ -30,6 +30,15 @@ public CreateAdCampaignParams paused(boolean paused) { return this; } + /** + * Hands targeting and creative rotation to the network. Needs its + * {@code smartPlus} capability. + */ + public CreateAdCampaignParams smartPlus(boolean smartPlus) { + body.put("smartPlus", smartPlus); + return this; + } + public Map toMap() { return new LinkedHashMap<>(body); } diff --git a/src/main/java/com/fopost/sdk/param/CreateAdParams.java b/src/main/java/com/fopost/sdk/param/CreateAdParams.java index 10f993d..19306dc 100644 --- a/src/main/java/com/fopost/sdk/param/CreateAdParams.java +++ b/src/main/java/com/fopost/sdk/param/CreateAdParams.java @@ -39,6 +39,17 @@ public static CreateAdParams of( return params; } + /** + * Run a post already live on the network as a Spark ad, from + * {@code ads().sparkPosts(...)}. The post carries its own caption and + * media, so {@code text}, {@code headline} and {@code mediaUrl} are + * ignored. Needs the network's {@code sparkAds} capability. + */ + public CreateAdParams sparkPostId(String sparkPostId) { + body.put("sparkPostId", sparkPostId); + return this; + } + public CreateAdParams headline(String headline) { body.put("headline", headline); return this; diff --git a/src/main/java/com/fopost/sdk/param/UploadConversionsParams.java b/src/main/java/com/fopost/sdk/param/UploadConversionsParams.java new file mode 100644 index 0000000..3236c76 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/UploadConversionsParams.java @@ -0,0 +1,53 @@ +package com.fopost.sdk.param; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The body of {@code POST /v1/ads/conversions}: offline events attributed to a + * pixel the ad account owns. Emails and phone numbers are hashed by the API + * before anything leaves FoPost. + */ +public final class UploadConversionsParams { + + private final Map body = new LinkedHashMap<>(); + private final List> events = new ArrayList<>(); + + private UploadConversionsParams() {} + + public static UploadConversionsParams of( + String workspaceId, String connectionId, String adAccountId, String pixelId) { + UploadConversionsParams params = new UploadConversionsParams(); + params.body.put("workspaceId", workspaceId); + params.body.put("connectionId", connectionId); + params.body.put("adAccountId", adAccountId); + params.body.put("pixelId", pixelId); + return params; + } + + /** Up to 1000 events per call. {@code occurredAt} is ISO 8601. */ + public UploadConversionsParams event(String eventName, String occurredAt) { + Map event = new LinkedHashMap<>(); + event.put("eventName", eventName); + event.put("occurredAt", occurredAt); + events.add(event); + return this; + } + + /** Adds a field to the event added last. */ + public UploadConversionsParams with(String field, Object value) { + if (events.isEmpty()) { + throw new IllegalStateException("Add an event before its fields"); + } + events.get(events.size() - 1).put(field, value); + return this; + } + + public Map toMap() { + Map out = new LinkedHashMap<>(body); + out.put("events", new ArrayList<>(events)); + return out; + } +} diff --git a/src/main/java/com/fopost/sdk/resource/AdsResource.java b/src/main/java/com/fopost/sdk/resource/AdsResource.java index 96d54c4..4369403 100644 --- a/src/main/java/com/fopost/sdk/resource/AdsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AdsResource.java @@ -3,9 +3,12 @@ import com.fopost.sdk.internal.ApiClient; import com.fopost.sdk.model.Ad; import com.fopost.sdk.model.AdAccountTree; +import com.fopost.sdk.model.AdBusinessCenter; import com.fopost.sdk.model.AdCampaign; +import com.fopost.sdk.model.AdCommentsPage; import com.fopost.sdk.model.AdConnection; import com.fopost.sdk.model.AdCreative; +import com.fopost.sdk.model.AdIdentity; import com.fopost.sdk.model.AdInsightsReport; import com.fopost.sdk.model.AdSet; import com.fopost.sdk.model.AdSource; @@ -23,6 +26,7 @@ import com.fopost.sdk.model.LeadsPage; import com.fopost.sdk.model.NetworkAd; import com.fopost.sdk.model.ReachEstimate; +import com.fopost.sdk.model.SparkPost; import com.fopost.sdk.model.TargetingOption; import com.fopost.sdk.param.AdInsightsParams; import com.fopost.sdk.param.BoostPostParams; @@ -38,6 +42,7 @@ import com.fopost.sdk.param.ReachEstimateParams; import com.fopost.sdk.param.UpdateAdCampaignParams; import com.fopost.sdk.param.UpdateAdSetParams; +import com.fopost.sdk.param.UploadConversionsParams; import com.fopost.sdk.param.UpdateAudienceParams; import com.fopost.sdk.param.UpdateNetworkAdParams; import java.util.LinkedHashMap; @@ -479,6 +484,107 @@ public List searchTargeting(String connectionId, String type, S ApiClient.unwrap(http.get("/v1/ads/targeting/search", query)), TargetingOption.class); } + // ─── Identities, Spark posts, conversions and ad comments ───────────────── + + public List tiktokBusinessCenters(String connectionId) { + return tiktokBusinessCenters(connectionId, null); + } + + /** + * TikTok's Business Centers. The one network-named read on this resource, + * because no other network groups ad accounts this way. + */ + public List tiktokBusinessCenters(String connectionId, String workspaceId) { + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/tiktok/business-centers", connectionQuery(workspaceId, connectionId))), + AdBusinessCenter.class); + } + + public List tiktokIdentities(String connectionId, String adAccountId) { + return tiktokIdentities(connectionId, adAccountId, null); + } + + /** The accounts an ad can run as; an identity id is a {@code pageId}. */ + public List tiktokIdentities(String connectionId, String adAccountId, String workspaceId) { + Map query = connectionQuery(workspaceId, connectionId); + query.put("ad_account_id", adAccountId); + return http.convertList(ApiClient.unwrap(http.get("/v1/ads/tiktok/identities", query)), AdIdentity.class); + } + + public List sparkPosts(String connectionId, String adAccountId, String identityId) { + return sparkPosts(connectionId, adAccountId, identityId, null); + } + + /** Posts already live under an identity, each a candidate Spark ad. */ + public List sparkPosts( + String connectionId, String adAccountId, String identityId, String workspaceId) { + Map query = connectionQuery(workspaceId, connectionId); + query.put("ad_account_id", adAccountId); + query.put("identity_id", identityId); + return http.convertList(ApiClient.unwrap(http.get("/v1/ads/spark-posts", query)), SparkPost.class); + } + + /** + * Offline conversions against a pixel the ad account owns. Identifiers are + * hashed before anything leaves FoPost. Returns how many the network took. + */ + public long uploadConversions(UploadConversionsParams params) { + return ApiClient.unwrap(http.post("/v1/ads/conversions", params.toMap())) + .path("accepted") + .asLong(); + } + + public AdCommentsPage comments(String connectionId, String adId) { + return comments(connectionId, adId, null, null); + } + + /** One page of an ad's comments; pass {@code nextCursor} back as {@code after}. */ + public AdCommentsPage comments(String connectionId, String adId, String after, String workspaceId) { + Map query = connectionQuery(workspaceId, connectionId); + query.put("ad_id", adId); + if (after != null) { + query.put("after", after); + } + return http.convert(ApiClient.unwrap(http.get("/v1/ads/comments", query)), AdCommentsPage.class); + } + + /** + * Answer a comment on an ad; returns the reply's id on the network. Needs + * the {@code publish} scope as well as {@code ads}. + */ + public String replyToComment( + String commentId, String workspaceId, String connectionId, String adId, String text) { + Map body = commentBody(workspaceId, connectionId, adId); + body.put("text", text); + return ApiClient.unwrap(http.post("/v1/ads/comments/" + commentId + "/reply", body)) + .path("replyId") + .asText(); + } + + /** Needs the {@code publish} scope as well as {@code ads}. */ + public void setCommentHidden( + String commentId, String workspaceId, String connectionId, String adId, boolean hidden) { + Map body = commentBody(workspaceId, connectionId, adId); + body.put("hidden", hidden); + http.post("/v1/ads/comments/" + commentId + "/hide", body); + } + + /** + * One already gone on the network succeeds. Needs the {@code publish} scope + * as well as {@code ads}. + */ + public void deleteComment(String commentId, String workspaceId, String connectionId, String adId) { + http.request("DELETE", "/v1/ads/comments/" + commentId, commentBody(workspaceId, connectionId, adId), null); + } + + private static Map commentBody(String workspaceId, String connectionId, String adId) { + Map body = new LinkedHashMap<>(); + body.put("workspaceId", workspaceId); + body.put("connectionId", connectionId); + body.put("adId", adId); + return body; + } + // ─── Lead forms ─────────────────────────────────────────────────────────── public List leadForms() { diff --git a/src/test/java/com/fopost/sdk/AdsTikTokTest.java b/src/test/java/com/fopost/sdk/AdsTikTokTest.java new file mode 100644 index 0000000..6c1c595 --- /dev/null +++ b/src/test/java/com/fopost/sdk/AdsTikTokTest.java @@ -0,0 +1,117 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.model.AdBusinessCenter; +import com.fopost.sdk.model.AdCommentsPage; +import com.fopost.sdk.model.AdIdentity; +import com.fopost.sdk.model.SparkPost; +import com.fopost.sdk.param.AdBudgetParams; +import com.fopost.sdk.param.AdTargetingParams; +import com.fopost.sdk.param.CreateAdCampaignParams; +import com.fopost.sdk.param.CreateAdParams; +import com.fopost.sdk.param.UploadConversionsParams; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AdsTikTokTest { + + @Test + void identitiesAndSparkPostsReadTheRightPaths() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":[{\"id\":\"bc1\",\"name\":\"Brand HQ\",\"role\":\"ADMIN\"}]}") + .enqueue(200, "{\"data\":[{\"id\":\"idt_1\",\"type\":\"CUSTOMIZED_USER\",\"name\":\"Your Brand\"," + + "\"avatarUrl\":null}]}") + .enqueue(200, "{\"data\":[{\"id\":\"item_99\",\"identityId\":\"idt_1\",\"caption\":null," + + "\"thumbnailUrl\":null,\"createdAt\":null,\"views\":48213}]}"); + + FoPost client = TestSupport.client(transport); + + List centers = client.ads().tiktokBusinessCenters("c1", "w1"); + assertEquals("Brand HQ", centers.get(0).name()); + assertTrue(transport.last().url().contains("/v1/ads/tiktok/business-centers")); + + List identities = client.ads().tiktokIdentities("c1", "7011", "w1"); + assertEquals("CUSTOMIZED_USER", identities.get(0).type()); + + List posts = client.ads().sparkPosts("c1", "7011", "idt_1", "w1"); + assertEquals(48213L, posts.get(0).views()); + assertTrue(transport.last().url().contains("identity_id=idt_1")); + } + + @Test + void sparkPostIdAndSmartPlusTravelInTheBody() { + FakeTransport transport = new FakeTransport() + .enqueue(201, "{\"data\":{\"id\":\"ad1\",\"workspaceId\":\"w1\",\"kind\":\"ad\",\"name\":\"Spark\"," + + "\"goal\":\"traffic\",\"status\":\"paused\"}}") + .enqueue(201, "{\"data\":{\"id\":\"c1\",\"name\":\"Smart\",\"status\":\"PAUSED\"}}"); + + FoPost client = TestSupport.client(transport); + + client.ads() + .create(CreateAdParams.of( + "w1", + "c1", + "7011", + "idt_1", + "Spark", + "traffic", + AdBudgetParams.daily(2000), + AdTargetingParams.create(List.of("US"), 18, 44, "all"), + "") + .sparkPostId("item_99")); + assertTrue(transport.lastBody().contains("\"sparkPostId\":\"item_99\"")); + + client.ads() + .createCampaign(CreateAdCampaignParams.of("w1", "c1", "7011", "Smart", "traffic") + .smartPlus(true)); + assertTrue(transport.lastBody().contains("\"smartPlus\":true")); + } + + @Test + void conversionsReportWhatTheNetworkAccepted() { + FakeTransport transport = new FakeTransport().enqueue(202, "{\"data\":{\"accepted\":2}}"); + + long accepted = TestSupport.client(transport) + .ads() + .uploadConversions(UploadConversionsParams.of("w1", "c1", "7011", "px_1") + .event("CompletePayment", "2026-09-18T10:04:00Z") + .with("valueMinor", 4999) + .event("CompletePayment", "2026-09-18T11:04:00Z")); + + assertEquals(2L, accepted); + assertTrue(transport.lastBody().contains("\"pixelId\":\"px_1\"")); + assertTrue(transport.lastBody().contains("\"valueMinor\":4999")); + } + + @Test + void commentsPageAndTheThreeWrites() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":{\"comments\":[{\"id\":\"cm1\",\"adId\":\"ad1\",\"text\":\"nice\"," + + "\"authorName\":null,\"authorAvatarUrl\":null,\"createdAt\":null,\"likes\":3," + + "\"replyCount\":0,\"hidden\":true,\"parentId\":null}],\"nextCursor\":\"2\"}}") + .enqueue(201, "{\"data\":{\"replyId\":\"cm2\"}}") + .enqueue(200, "{\"message\":\"Comment hidden\"}") + .enqueue(200, "{\"message\":\"Comment deleted\"}"); + + FoPost client = TestSupport.client(transport); + + AdCommentsPage page = client.ads().comments("c1", "ad1", null, "w1"); + assertEquals("2", page.nextCursor()); + assertTrue(page.comments().get(0).hidden()); + assertEquals(3L, page.comments().get(0).likes()); + + assertEquals("cm2", client.ads().replyToComment("cm1", "w1", "c1", "ad1", "Friday!")); + assertTrue(transport.last().url().endsWith("/v1/ads/comments/cm1/reply")); + assertTrue(transport.lastBody().contains("\"adId\":\"ad1\"")); + + client.ads().setCommentHidden("cm1", "w1", "c1", "ad1", true); + assertTrue(transport.lastBody().contains("\"hidden\":true")); + + client.ads().deleteComment("cm1", "w1", "c1", "ad1"); + // The ad travels in the body, because the path already carries the comment. + assertEquals("DELETE", transport.last().method()); + assertTrue(transport.lastBody().contains("\"adId\":\"ad1\"")); + } +}