From f0fde7facdbaaf9faa7fc04259690c6ce267f435 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:50:33 +0200 Subject: [PATCH 1/4] feat(internal): per-request headers on ApiClient.request --- src/main/java/com/fopost/sdk/internal/ApiClient.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/fopost/sdk/internal/ApiClient.java b/src/main/java/com/fopost/sdk/internal/ApiClient.java index 47daeb0..81c0d71 100644 --- a/src/main/java/com/fopost/sdk/internal/ApiClient.java +++ b/src/main/java/com/fopost/sdk/internal/ApiClient.java @@ -88,7 +88,16 @@ public JsonNode delete(String path) { /** Send a request and return the decoded body, retrying while the API answers 429. */ public JsonNode request(String method, String path, Object body, Map query) { + return request(method, path, body, query, null); + } + + /** As above, with extra request headers such as {@code Idempotency-Key}. */ + public JsonNode request( + String method, String path, Object body, Map query, Map extraHeaders) { Map headers = baseHeaders(); + if (extraHeaders != null) { + headers.putAll(extraHeaders); + } String url = url(path, query); HttpRequestData request; if (body instanceof Multipart multipart) { From 4d4577a329a21b5eea285684069712cd87fd9d98 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:50:33 +0200 Subject: [PATCH 2/4] feat(ads): pixels, conversions API and custom conversions --- .../java/com/fopost/sdk/model/AdPixel.java | 4 + .../com/fopost/sdk/model/AdPixelDetail.java | 14 ++ .../com/fopost/sdk/model/AdPixelStats.java | 6 + .../com/fopost/sdk/model/ConversionBatch.java | 16 ++ .../sdk/model/ConversionUploadResult.java | 7 + .../fopost/sdk/model/CustomConversion.java | 15 ++ .../sdk/model/CustomConversionRule.java | 4 + .../fopost/sdk/model/EventMatchQuality.java | 6 + .../sdk/model/HashedConversionEvent.java | 14 ++ .../fopost/sdk/model/MatchKeyCoverage.java | 4 + .../com/fopost/sdk/model/PixelEventCount.java | 3 + .../param/CreateCustomConversionParams.java | 67 +++++++ .../param/UpdateCustomConversionParams.java | 39 +++++ .../fopost/sdk/param/UpdatePixelParams.java | 35 ++++ .../sdk/param/UploadConversionsParams.java | 8 +- .../com/fopost/sdk/resource/AdsResource.java | 163 +++++++++++++++++- .../java/com/fopost/sdk/AdsTikTokTest.java | 6 +- 17 files changed, 402 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/fopost/sdk/model/AdPixel.java create mode 100644 src/main/java/com/fopost/sdk/model/AdPixelDetail.java create mode 100644 src/main/java/com/fopost/sdk/model/AdPixelStats.java create mode 100644 src/main/java/com/fopost/sdk/model/ConversionBatch.java create mode 100644 src/main/java/com/fopost/sdk/model/ConversionUploadResult.java create mode 100644 src/main/java/com/fopost/sdk/model/CustomConversion.java create mode 100644 src/main/java/com/fopost/sdk/model/CustomConversionRule.java create mode 100644 src/main/java/com/fopost/sdk/model/EventMatchQuality.java create mode 100644 src/main/java/com/fopost/sdk/model/HashedConversionEvent.java create mode 100644 src/main/java/com/fopost/sdk/model/MatchKeyCoverage.java create mode 100644 src/main/java/com/fopost/sdk/model/PixelEventCount.java create mode 100644 src/main/java/com/fopost/sdk/param/CreateCustomConversionParams.java create mode 100644 src/main/java/com/fopost/sdk/param/UpdateCustomConversionParams.java create mode 100644 src/main/java/com/fopost/sdk/param/UpdatePixelParams.java diff --git a/src/main/java/com/fopost/sdk/model/AdPixel.java b/src/main/java/com/fopost/sdk/model/AdPixel.java new file mode 100644 index 0000000..732db51 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdPixel.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** A pixel the ad account owns. */ +public record AdPixel(String id, String name) {} diff --git a/src/main/java/com/fopost/sdk/model/AdPixelDetail.java b/src/main/java/com/fopost/sdk/model/AdPixelDetail.java new file mode 100644 index 0000000..5cebfdd --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdPixelDetail.java @@ -0,0 +1,14 @@ +package com.fopost.sdk.model; + +/** + * One pixel in full. {@code code} is the script to place on the site; {@code dataUseSetting} is + * {@code advertising_and_analytics} or {@code analytics_only}. + */ +public record AdPixelDetail( + String id, + String name, + String code, + String createdAt, + String lastFiredAt, + boolean isUnavailable, + String dataUseSetting) {} diff --git a/src/main/java/com/fopost/sdk/model/AdPixelStats.java b/src/main/java/com/fopost/sdk/model/AdPixelStats.java new file mode 100644 index 0000000..7478af3 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdPixelStats.java @@ -0,0 +1,6 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** How often each event fired between {@code since} and {@code until} (the last 28 days). */ +public record AdPixelStats(String since, String until, List events) {} diff --git a/src/main/java/com/fopost/sdk/model/ConversionBatch.java b/src/main/java/com/fopost/sdk/model/ConversionBatch.java new file mode 100644 index 0000000..8d7e141 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ConversionBatch.java @@ -0,0 +1,16 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** A stored conversion upload and the hashed events it carried. */ +public record ConversionBatch( + String id, + String workspaceId, + String connectionId, + String adAccountId, + String pixelId, + String idempotencyKey, + long eventCount, + long accepted, + List events, + String createdAt) {} diff --git a/src/main/java/com/fopost/sdk/model/ConversionUploadResult.java b/src/main/java/com/fopost/sdk/model/ConversionUploadResult.java new file mode 100644 index 0000000..8e7389e --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ConversionUploadResult.java @@ -0,0 +1,7 @@ +package com.fopost.sdk.model; + +/** + * What a conversion upload came back with. {@code replayed} is true when the + * {@code Idempotency-Key} had been used before and nothing was sent again. + */ +public record ConversionUploadResult(String id, long accepted, long eventCount, boolean replayed) {} diff --git a/src/main/java/com/fopost/sdk/model/CustomConversion.java b/src/main/java/com/fopost/sdk/model/CustomConversion.java new file mode 100644 index 0000000..4bde867 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/CustomConversion.java @@ -0,0 +1,15 @@ +package com.fopost.sdk.model; + +/** A conversion the account defines on top of a pixel. {@code defaultValueMinor} is in minor units. */ +public record CustomConversion( + String id, + String name, + String pixelId, + String eventType, + CustomConversionRule rule, + String description, + Long defaultValueMinor, + boolean isArchived, + boolean isUnavailable, + String createdAt, + String lastFiredAt) {} diff --git a/src/main/java/com/fopost/sdk/model/CustomConversionRule.java b/src/main/java/com/fopost/sdk/model/CustomConversionRule.java new file mode 100644 index 0000000..3c27c40 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/CustomConversionRule.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** The pixel event a custom conversion counts, narrowed by one URL condition. */ +public record CustomConversionRule(String eventName, String urlContains, String urlEquals) {} diff --git a/src/main/java/com/fopost/sdk/model/EventMatchQuality.java b/src/main/java/com/fopost/sdk/model/EventMatchQuality.java new file mode 100644 index 0000000..ad55819 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/EventMatchQuality.java @@ -0,0 +1,6 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** The network's match score for one event, out of 10, and which identifiers were sent. */ +public record EventMatchQuality(String eventName, Double score, List matchKeys) {} diff --git a/src/main/java/com/fopost/sdk/model/HashedConversionEvent.java b/src/main/java/com/fopost/sdk/model/HashedConversionEvent.java new file mode 100644 index 0000000..8707528 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/HashedConversionEvent.java @@ -0,0 +1,14 @@ +package com.fopost.sdk.model; + +/** One event of a stored batch. Identifiers are kept only as SHA-256 hashes. */ +public record HashedConversionEvent( + String eventName, + String occurredAt, + String eventId, + String actionSource, + String emailHash, + String phoneHash, + String externalIdHash, + Long valueMinor, + String currency, + String orderId) {} diff --git a/src/main/java/com/fopost/sdk/model/MatchKeyCoverage.java b/src/main/java/com/fopost/sdk/model/MatchKeyCoverage.java new file mode 100644 index 0000000..b8116dc --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/MatchKeyCoverage.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** How many of an event's sends carried one identifier, as a percentage. */ +public record MatchKeyCoverage(String identifier, double coveragePercent) {} diff --git a/src/main/java/com/fopost/sdk/model/PixelEventCount.java b/src/main/java/com/fopost/sdk/model/PixelEventCount.java new file mode 100644 index 0000000..11437d2 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/PixelEventCount.java @@ -0,0 +1,3 @@ +package com.fopost.sdk.model; + +public record PixelEventCount(String eventName, long count) {} diff --git a/src/main/java/com/fopost/sdk/param/CreateCustomConversionParams.java b/src/main/java/com/fopost/sdk/param/CreateCustomConversionParams.java new file mode 100644 index 0000000..0023404 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/CreateCustomConversionParams.java @@ -0,0 +1,67 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Defines a custom conversion on a pixel. {@code eventType} is one of the network's standard + * types ({@code PURCHASE}, {@code LEAD}, {@code OTHER}, ...). The rule reads {@code ruleEventName} + * ({@code PageView} by default) narrowed by {@link #urlContains} or {@link #urlEquals}, one of + * the two. + */ +public final class CreateCustomConversionParams { + + private final Map body = new LinkedHashMap<>(); + private final Map rule = new LinkedHashMap<>(); + + private CreateCustomConversionParams() {} + + public static CreateCustomConversionParams of( + String workspaceId, + String connectionId, + String adAccountId, + String pixelId, + String name, + String eventType) { + CreateCustomConversionParams params = new CreateCustomConversionParams(); + params.body.put("workspaceId", workspaceId); + params.body.put("connectionId", connectionId); + params.body.put("adAccountId", adAccountId); + params.body.put("pixelId", pixelId); + params.body.put("name", name); + params.body.put("eventType", eventType); + return params; + } + + public CreateCustomConversionParams ruleEventName(String eventName) { + rule.put("eventName", eventName); + return this; + } + + public CreateCustomConversionParams urlContains(String value) { + rule.put("urlContains", value); + return this; + } + + public CreateCustomConversionParams urlEquals(String url) { + rule.put("urlEquals", url); + return this; + } + + public CreateCustomConversionParams description(String description) { + body.put("description", description); + return this; + } + + /** Default value of one conversion, account currency, minor units. */ + public CreateCustomConversionParams defaultValueMinor(long defaultValueMinor) { + body.put("defaultValueMinor", defaultValueMinor); + return this; + } + + public Map toMap() { + Map out = new LinkedHashMap<>(body); + out.put("rule", new LinkedHashMap<>(rule)); + return out; + } +} diff --git a/src/main/java/com/fopost/sdk/param/UpdateCustomConversionParams.java b/src/main/java/com/fopost/sdk/param/UpdateCustomConversionParams.java new file mode 100644 index 0000000..9d1bea0 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/UpdateCustomConversionParams.java @@ -0,0 +1,39 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Changes a custom conversion's name, description or default value. The rule cannot change. */ +public final class UpdateCustomConversionParams { + + private final Map body = new LinkedHashMap<>(); + + private UpdateCustomConversionParams() {} + + public static UpdateCustomConversionParams of(String workspaceId, String connectionId, String adAccountId) { + UpdateCustomConversionParams params = new UpdateCustomConversionParams(); + params.body.put("workspaceId", workspaceId); + params.body.put("connectionId", connectionId); + params.body.put("adAccountId", adAccountId); + return params; + } + + public UpdateCustomConversionParams name(String name) { + body.put("name", name); + return this; + } + + public UpdateCustomConversionParams description(String description) { + body.put("description", description); + return this; + } + + public UpdateCustomConversionParams defaultValueMinor(long defaultValueMinor) { + body.put("defaultValueMinor", defaultValueMinor); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/UpdatePixelParams.java b/src/main/java/com/fopost/sdk/param/UpdatePixelParams.java new file mode 100644 index 0000000..47ade70 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/UpdatePixelParams.java @@ -0,0 +1,35 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Renames a pixel or changes what its data may be used for. */ +public final class UpdatePixelParams { + + private final Map body = new LinkedHashMap<>(); + + private UpdatePixelParams() {} + + public static UpdatePixelParams of(String workspaceId, String connectionId, String adAccountId) { + UpdatePixelParams params = new UpdatePixelParams(); + params.body.put("workspaceId", workspaceId); + params.body.put("connectionId", connectionId); + params.body.put("adAccountId", adAccountId); + return params; + } + + public UpdatePixelParams name(String name) { + body.put("name", name); + return this; + } + + /** {@code advertising_and_analytics} or {@code analytics_only}. */ + public UpdatePixelParams dataUseSetting(String dataUseSetting) { + body.put("dataUseSetting", dataUseSetting); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/UploadConversionsParams.java b/src/main/java/com/fopost/sdk/param/UploadConversionsParams.java index 3236c76..7967779 100644 --- a/src/main/java/com/fopost/sdk/param/UploadConversionsParams.java +++ b/src/main/java/com/fopost/sdk/param/UploadConversionsParams.java @@ -7,8 +7,12 @@ /** * 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. + * pixel the ad account owns. Emails, phone numbers and external ids are hashed + * by the API before anything leaves FoPost. Per-event fields go through + * {@link #with}: {@code eventId}, {@code actionSource}, {@code sourceUrl}, + * {@code email}, {@code phone}, {@code externalId}, {@code clientIpAddress}, + * {@code clientUserAgent}, {@code clickId}, {@code browserId}, {@code valueMinor}, + * {@code currency}, {@code orderId} and {@code contentIds}. */ public final class UploadConversionsParams { diff --git a/src/main/java/com/fopost/sdk/resource/AdsResource.java b/src/main/java/com/fopost/sdk/resource/AdsResource.java index cc1b2c9..b6f0f9a 100644 --- a/src/main/java/com/fopost/sdk/resource/AdsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AdsResource.java @@ -80,6 +80,16 @@ import com.fopost.sdk.param.ReachEstimateParams; import com.fopost.sdk.param.UpdateAdCampaignParams; import com.fopost.sdk.param.UpdateAdSetParams; +import com.fopost.sdk.model.AdPixel; +import com.fopost.sdk.model.AdPixelDetail; +import com.fopost.sdk.model.AdPixelStats; +import com.fopost.sdk.model.ConversionBatch; +import com.fopost.sdk.model.ConversionUploadResult; +import com.fopost.sdk.model.CustomConversion; +import com.fopost.sdk.model.EventMatchQuality; +import com.fopost.sdk.param.CreateCustomConversionParams; +import com.fopost.sdk.param.UpdateCustomConversionParams; +import com.fopost.sdk.param.UpdatePixelParams; import com.fopost.sdk.param.UploadConversionsParams; import com.fopost.sdk.param.UpdateAudienceParams; import com.fopost.sdk.param.UpdateConversionRuleParams; @@ -705,12 +715,155 @@ public List sparkPosts( /** * Offline conversions against a pixel the ad account owns. Identifiers are - * hashed before anything leaves FoPost. Returns how many the network took. + * hashed before anything leaves FoPost. {@code accepted} is how many the network took. */ - public long uploadConversions(UploadConversionsParams params) { - return ApiClient.unwrap(http.post("/v1/ads/conversions", params.toMap())) - .path("accepted") - .asLong(); + public ConversionUploadResult uploadConversions(UploadConversionsParams params) { + return uploadConversions(params, null); + } + + /** As above, with an optional {@code Idempotency-Key}; a repeat answers {@code replayed}. */ + public ConversionUploadResult uploadConversions(UploadConversionsParams params, String idempotencyKey) { + return http.convert( + ApiClient.unwrap(http.request( + "POST", "/v1/ads/conversions", params.toMap(), null, idempotencyHeader(idempotencyKey))), + ConversionUploadResult.class); + } + + /** + * The idempotent send: the same key within the retention window answers the stored batch + * with {@code replayed} true and sends nothing. Same body as {@link #uploadConversions}. + */ + public ConversionUploadResult sendPixelConversions(UploadConversionsParams params, String idempotencyKey) { + if (idempotencyKey == null || idempotencyKey.isBlank()) { + throw new IllegalArgumentException("idempotencyKey is required"); + } + return http.convert( + ApiClient.unwrap(http.request( + "POST", + "/v1/ads/conversions/events", + params.toMap(), + null, + idempotencyHeader(idempotencyKey))), + ConversionUploadResult.class); + } + + /** Stored conversion batches, newest first. Every argument may be null. */ + public List listConversionBatches(String workspaceId, String connectionId, Integer limit) { + Map query = workspaceQuery(workspaceId); + if (connectionId != null) { + query.put("connection_id", connectionId); + } + if (limit != null) { + query.put("limit", limit); + } + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/conversions/events", query)), ConversionBatch.class); + } + + public List pixels(String workspaceId, String connectionId, String adAccountId) { + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/pixels", accountQuery(workspaceId, connectionId, adAccountId))), + AdPixel.class); + } + + /** Creates a pixel; the answer carries the script to place on the site. */ + public AdPixelDetail createPixel(String workspaceId, String connectionId, String adAccountId, String name) { + Map body = new LinkedHashMap<>(); + body.put("workspaceId", workspaceId); + body.put("connectionId", connectionId); + body.put("adAccountId", adAccountId); + body.put("name", name); + return http.convert(ApiClient.unwrap(http.post("/v1/ads/pixels", body)), AdPixelDetail.class); + } + + public AdPixelDetail pixel(String pixelId, String workspaceId, String connectionId, String adAccountId) { + return http.convert( + ApiClient.unwrap(http.get( + pixelPath(pixelId, ""), accountQuery(workspaceId, connectionId, adAccountId))), + AdPixelDetail.class); + } + + public AdPixelDetail updatePixel(String pixelId, UpdatePixelParams params) { + return http.convert( + ApiClient.unwrap(http.patch(pixelPath(pixelId, ""), params.toMap())), AdPixelDetail.class); + } + + /** Shares the pixel with another ad account under the same business portfolio. */ + public void sharePixel( + String pixelId, String workspaceId, String connectionId, String adAccountId, String targetAdAccountId) { + Map body = new LinkedHashMap<>(); + body.put("workspaceId", workspaceId); + body.put("connectionId", connectionId); + body.put("adAccountId", adAccountId); + body.put("targetAdAccountId", targetAdAccountId); + http.post(pixelPath(pixelId, "/share"), body); + } + + /** Event counts over the last 28 days. */ + public AdPixelStats pixelStats(String pixelId, String workspaceId, String connectionId, String adAccountId) { + return http.convert( + ApiClient.unwrap(http.get( + pixelPath(pixelId, "/stats"), accountQuery(workspaceId, connectionId, adAccountId))), + AdPixelStats.class); + } + + /** The network's event match quality per event. */ + public List eventMatchQuality( + String pixelId, String workspaceId, String connectionId, String adAccountId) { + return http.convertList( + ApiClient.unwrap(http.get( + pixelPath(pixelId, "/match-quality"), + accountQuery(workspaceId, connectionId, adAccountId))), + EventMatchQuality.class); + } + + public List customConversions(String workspaceId, String connectionId, String adAccountId) { + return http.convertList( + ApiClient.unwrap(http.get( + "/v1/ads/custom-conversions", accountQuery(workspaceId, connectionId, adAccountId))), + CustomConversion.class); + } + + public CustomConversion createCustomConversion(CreateCustomConversionParams params) { + return http.convert( + ApiClient.unwrap(http.post("/v1/ads/custom-conversions", params.toMap())), CustomConversion.class); + } + + public CustomConversion customConversion( + String customConversionId, String workspaceId, String connectionId, String adAccountId) { + return http.convert( + ApiClient.unwrap(http.get( + customConversionPath(customConversionId), + accountQuery(workspaceId, connectionId, adAccountId))), + CustomConversion.class); + } + + public CustomConversion updateCustomConversion(String customConversionId, UpdateCustomConversionParams params) { + return http.convert( + ApiClient.unwrap(http.patch(customConversionPath(customConversionId), params.toMap())), + CustomConversion.class); + } + + /** The account travels in the body, because the path already carries the conversion. */ + public void deleteCustomConversion( + String customConversionId, String workspaceId, String connectionId, String adAccountId) { + Map body = new LinkedHashMap<>(); + body.put("workspaceId", workspaceId); + body.put("connectionId", connectionId); + body.put("adAccountId", adAccountId); + http.request("DELETE", customConversionPath(customConversionId), body, null); + } + + private static Map idempotencyHeader(String key) { + return key == null ? null : Map.of("Idempotency-Key", key); + } + + private static String pixelPath(String pixelId, String suffix) { + return "/v1/ads/pixels/" + pixelId + suffix; + } + + private static String customConversionPath(String id) { + return "/v1/ads/custom-conversions/" + id; } public AdCommentsPage comments(String connectionId, String adId) { diff --git a/src/test/java/com/fopost/sdk/AdsTikTokTest.java b/src/test/java/com/fopost/sdk/AdsTikTokTest.java index 6c1c595..d525bd7 100644 --- a/src/test/java/com/fopost/sdk/AdsTikTokTest.java +++ b/src/test/java/com/fopost/sdk/AdsTikTokTest.java @@ -71,14 +71,16 @@ void sparkPostIdAndSmartPlusTravelInTheBody() { @Test void conversionsReportWhatTheNetworkAccepted() { - FakeTransport transport = new FakeTransport().enqueue(202, "{\"data\":{\"accepted\":2}}"); + FakeTransport transport = new FakeTransport() + .enqueue(202, "{\"data\":{\"id\":\"b1\",\"accepted\":2,\"eventCount\":2,\"replayed\":false}}"); 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")); + .event("CompletePayment", "2026-09-18T11:04:00Z")) + .accepted(); assertEquals(2L, accepted); assertTrue(transport.lastBody().contains("\"pixelId\":\"px_1\"")); From e8419a768ee254f6ac99b925acdc449812b9ca16 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:50:34 +0200 Subject: [PATCH 3/4] test(ads): idempotent send, pixel reads and custom conversion create --- .../java/com/fopost/sdk/AdsTrackingTest.java | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/test/java/com/fopost/sdk/AdsTrackingTest.java diff --git a/src/test/java/com/fopost/sdk/AdsTrackingTest.java b/src/test/java/com/fopost/sdk/AdsTrackingTest.java new file mode 100644 index 0000000..76e24aa --- /dev/null +++ b/src/test/java/com/fopost/sdk/AdsTrackingTest.java @@ -0,0 +1,125 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.model.AdPixelDetail; +import com.fopost.sdk.model.ConversionBatch; +import com.fopost.sdk.model.ConversionUploadResult; +import com.fopost.sdk.model.CustomConversion; +import com.fopost.sdk.model.EventMatchQuality; +import com.fopost.sdk.param.CreateCustomConversionParams; +import com.fopost.sdk.param.UploadConversionsParams; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AdsTrackingTest { + + private static final String RESULT = + "{\"data\":{\"id\":\"b1\",\"accepted\":1,\"eventCount\":1,\"replayed\":true}}"; + + @Test + void sendPixelConversionsCarriesTheIdempotencyKey() { + FakeTransport transport = new FakeTransport().enqueue(202, RESULT); + + ConversionUploadResult result = TestSupport.client(transport) + .ads() + .sendPixelConversions( + UploadConversionsParams.of("w1", "c1", "act_1", "px_1") + .event("Purchase", "2026-10-18T10:04:00Z") + .with("eventId", "order-9") + .with("actionSource", "website") + .with("contentIds", List.of("sku_1")) + .with("valueMinor", 4999), + "key-9"); + + assertTrue(result.replayed()); + assertEquals("b1", result.id()); + assertEquals("key-9", transport.last().headers().get("Idempotency-Key")); + assertTrue(transport.last().url().endsWith("/v1/ads/conversions/events")); + assertTrue(transport.lastBody().contains("\"contentIds\":[\"sku_1\"]")); + assertTrue(transport.lastBody().contains("\"actionSource\":\"website\"")); + + assertThrows(IllegalArgumentException.class, () -> TestSupport.client(transport) + .ads() + .sendPixelConversions(UploadConversionsParams.of("w1", "c1", "act_1", "px_1"), " ")); + } + + @Test + void uploadConversionsSendsTheKeyOnlyWhenGiven() { + FakeTransport transport = new FakeTransport().enqueue(202, RESULT).enqueue(202, RESULT); + FoPost client = TestSupport.client(transport); + UploadConversionsParams params = + UploadConversionsParams.of("w1", "c1", "act_1", "px_1").event("Lead", "2026-10-18T10:04:00Z"); + + client.ads().uploadConversions(params); + assertNull(transport.last().headers().get("Idempotency-Key")); + + client.ads().uploadConversions(params, "key-1"); + assertEquals("key-1", transport.last().headers().get("Idempotency-Key")); + assertTrue(transport.last().url().endsWith("/v1/ads/conversions")); + } + + @Test + void conversionBatchesListWithOptionalFilters() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":[{\"id\":\"b1\",\"workspaceId\":\"w1\",\"connectionId\":\"c1\"," + + "\"adAccountId\":\"act_1\",\"pixelId\":\"px_1\",\"idempotencyKey\":null," + + "\"eventCount\":1,\"accepted\":1,\"events\":[{\"eventName\":\"Purchase\"," + + "\"occurredAt\":\"2026-10-18T10:04:00Z\",\"eventId\":null,\"actionSource\":\"website\"," + + "\"emailHash\":\"ab\",\"phoneHash\":null,\"externalIdHash\":null,\"valueMinor\":4999," + + "\"currency\":\"USD\",\"orderId\":null}],\"createdAt\":\"2026-10-18T10:05:00Z\"}]}"); + + List batches = TestSupport.client(transport).ads().listConversionBatches("w1", null, 5); + + assertEquals("ab", batches.get(0).events().get(0).emailHash()); + assertNull(batches.get(0).idempotencyKey()); + String url = transport.last().url(); + assertTrue(url.contains("/v1/ads/conversions/events?")); + assertTrue(url.contains("limit=5")); + assertTrue(!url.contains("connection_id")); + } + + @Test + void pixelReadsMatchQualityAndCustomConversionCreate() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":{\"id\":\"px_1\",\"name\":\"Site\",\"code\":\"\"," + + "\"createdAt\":null,\"lastFiredAt\":\"2026-10-17T00:00:00Z\",\"isUnavailable\":false," + + "\"dataUseSetting\":\"analytics_only\"}}") + .enqueue(200, "{\"data\":[{\"eventName\":\"Purchase\",\"score\":7.5," + + "\"matchKeys\":[{\"identifier\":\"email\",\"coveragePercent\":80}]}]}") + .enqueue(201, "{\"data\":{\"id\":\"cc_1\",\"name\":\"Signup\",\"pixelId\":\"px_1\"," + + "\"eventType\":\"LEAD\",\"rule\":{\"eventName\":\"PageView\",\"urlContains\":\"/thanks\"}," + + "\"description\":null,\"defaultValueMinor\":500,\"isArchived\":false," + + "\"isUnavailable\":false,\"createdAt\":null,\"lastFiredAt\":null}}") + .enqueue(204, ""); + FoPost client = TestSupport.client(transport); + + AdPixelDetail pixel = client.ads().pixel("px_1", "w1", "c1", "act_1"); + assertEquals("analytics_only", pixel.dataUseSetting()); + assertEquals("", pixel.code()); + assertTrue(transport.last().url().contains("/v1/ads/pixels/px_1?")); + assertTrue(transport.last().url().contains("ad_account_id=act_1")); + + List quality = client.ads().eventMatchQuality("px_1", "w1", "c1", "act_1"); + assertEquals(7.5, quality.get(0).score()); + assertEquals(80.0, quality.get(0).matchKeys().get(0).coveragePercent()); + assertTrue(transport.last().url().contains("/v1/ads/pixels/px_1/match-quality?")); + + CustomConversion created = client.ads() + .createCustomConversion(CreateCustomConversionParams.of("w1", "c1", "act_1", "px_1", "Signup", "LEAD") + .urlContains("/thanks") + .defaultValueMinor(500)); + assertEquals("/thanks", created.rule().urlContains()); + assertEquals(500L, created.defaultValueMinor()); + assertTrue(transport.last().url().endsWith("/v1/ads/custom-conversions")); + assertTrue(transport.lastBody().contains("\"rule\":{\"urlContains\":\"/thanks\"}")); + assertTrue(transport.lastBody().contains("\"eventType\":\"LEAD\"")); + + client.ads().deleteCustomConversion("cc_1", "w1", "c1", "act_1"); + assertEquals("DELETE", transport.last().method()); + assertTrue(transport.lastBody().contains("\"adAccountId\":\"act_1\"")); + } +} From 38427b4640e0a918ccf0f39e2cfe46e418bec6ec Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 20:50:34 +0200 Subject: [PATCH 4/4] docs: ads tracking in README and changelog --- CHANGELOG.md | 20 ++++++++++++++++++++ README.md | 27 ++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d08096..f3af598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- `ads().sendPixelConversions(params, idempotencyKey)` sends Conversions API events with a + required `Idempotency-Key`; a replay answers `ConversionUploadResult.replayed()` true. + `ads().listConversionBatches` lists what was stored, with hashed identifiers only. +- Pixels: `ads().pixels`, `createPixel`, `pixel`, `updatePixel`, `sharePixel`, `pixelStats` and + `eventMatchQuality`, with `AdPixel`, `AdPixelDetail`, `AdPixelStats` and `EventMatchQuality`. +- Custom conversions: `ads().customConversions`, `createCustomConversion`, `customConversion`, + `updateCustomConversion` and `deleteCustomConversion`, with `CustomConversion` and + `CustomConversionRule`. +- `UploadConversionsParams` events accept `eventId`, `actionSource`, `sourceUrl`, `externalId`, + `clientIpAddress`, `clientUserAgent`, `clickId`, `browserId` and `contentIds` through `with`. + +### Changed + +- `ads().uploadConversions` returns `ConversionUploadResult` (`id`, `accepted`, `eventCount`, + `replayed`) instead of the accepted count, and takes an optional `idempotencyKey`. + ## [0.3.0] - 2026-10-18 ### Added diff --git a/README.md b/README.md index 1869ada..9752a0d 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `broadcasts()` | `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`, `recipients` | | `sequences()` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` | | `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` | +| `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`, `uploadConversions`, `sendPixelConversions`, `listConversionBatches`, `pixels`, `createPixel`, `pixel`, `updatePixel`, `sharePixel`, `pixelStats`, `eventMatchQuality`, `customConversions`, `createCustomConversion`, `customConversion`, `updateCustomConversion`, `deleteCustomConversion` | | `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `vote`, `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` | | `whatsapp()` | `getProfile`, `updateProfile`, `requestDisplayName`, `setUsername`, `listTemplates`, `listTemplateLibrary`, `getTemplate`, `createTemplate`, `importTemplate`, `updateTemplate`, `deleteTemplate`, `listGroups`, `createGroup`, `getGroup`, `updateGroup`, `deleteGroup`, `getGroupInviteLink`, `resetGroupInviteLink`, `removeGroupParticipants`, `listBlocked`, `blockUsers`, `unblockUsers`, `getCommerceSettings`, `updateCommerceSettings`, `linkCatalog`, `listFlows`, `getFlow`, `createFlow`, `updateFlow`, `deleteFlow`, `uploadFlowJson`, `publishFlow`, `deprecateFlow`, `listFlowResponses`, `getEncryptionKeyStatus`, `setEncryptionKey`, `getAccountEvents`, `listSandboxSessions`, `createSandboxSession` | @@ -447,6 +447,31 @@ Creating, updating, deleting and duplicating campaigns, ad sets and network ads, `bulkSetStatus`, need the `publish` scope as well as `ads`. `leadsFeed` pages with a cursor: pass `nextCursor` back through `LeadsFeedParams.cursor(...)` until it is null. +### Tracking + +Pixels, the Conversions API and custom conversions, all on the `ads` scope. `sendPixelConversions` +needs an `Idempotency-Key`: the same key within the retention window answers the stored batch with +`replayed()` true and sends nothing. `uploadConversions` takes the key as an optional second argument. + +```java +AdPixel pixel = client.ads().pixels(workspace.id(), connectionId, "act_123").get(0); + +ConversionUploadResult sent = client.ads().sendPixelConversions( + UploadConversionsParams.of(workspace.id(), connectionId, "act_123", pixel.id()) + .event("Purchase", "2026-10-18T10:04:00Z") + .with("eventId", order.id()) + .with("email", order.email()) + .with("valueMinor", 4999) + .with("currency", "USD"), + order.id()); + +CustomConversion signup = client.ads().createCustomConversion( + CreateCustomConversionParams.of(workspace.id(), connectionId, "act_123", pixel.id(), "Signup", "LEAD") + .urlContains("/thanks")); + +List quality = client.ads().eventMatchQuality(pixel.id(), workspace.id(), connectionId, "act_123"); +``` + ### Google Ads Campaigns, ad groups, ads, audiences, insights, labels, change history, experiments and