Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.4.0] - 2026-10-18

### Added
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -450,6 +450,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<EventMatchQuality> quality = client.ads().eventMatchQuality(pixel.id(), workspace.id(), connectionId, "act_123");
```

### Google Ads

Campaigns, ad groups, ads, audiences, insights, labels, change history, experiments and
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/fopost/sdk/internal/ApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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<String, Object> query, Map<String, String> extraHeaders) {
Map<String, String> headers = baseHeaders();
if (extraHeaders != null) {
headers.putAll(extraHeaders);
}
String url = url(path, query);
HttpRequestData request;
if (body instanceof Multipart multipart) {
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/AdPixel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** A pixel the ad account owns. */
public record AdPixel(String id, String name) {}
14 changes: 14 additions & 0 deletions src/main/java/com/fopost/sdk/model/AdPixelDetail.java
Original file line number Diff line number Diff line change
@@ -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) {}
6 changes: 6 additions & 0 deletions src/main/java/com/fopost/sdk/model/AdPixelStats.java
Original file line number Diff line number Diff line change
@@ -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<PixelEventCount> events) {}
16 changes: 16 additions & 0 deletions src/main/java/com/fopost/sdk/model/ConversionBatch.java
Original file line number Diff line number Diff line change
@@ -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<HashedConversionEvent> events,
String createdAt) {}
Original file line number Diff line number Diff line change
@@ -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) {}
15 changes: 15 additions & 0 deletions src/main/java/com/fopost/sdk/model/CustomConversion.java
Original file line number Diff line number Diff line change
@@ -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) {}
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/CustomConversionRule.java
Original file line number Diff line number Diff line change
@@ -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) {}
6 changes: 6 additions & 0 deletions src/main/java/com/fopost/sdk/model/EventMatchQuality.java
Original file line number Diff line number Diff line change
@@ -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<MatchKeyCoverage> matchKeys) {}
14 changes: 14 additions & 0 deletions src/main/java/com/fopost/sdk/model/HashedConversionEvent.java
Original file line number Diff line number Diff line change
@@ -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) {}
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/MatchKeyCoverage.java
Original file line number Diff line number Diff line change
@@ -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) {}
3 changes: 3 additions & 0 deletions src/main/java/com/fopost/sdk/model/PixelEventCount.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.fopost.sdk.model;

public record PixelEventCount(String eventName, long count) {}
Original file line number Diff line number Diff line change
@@ -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<String, Object> body = new LinkedHashMap<>();
private final Map<String, Object> 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<String, Object> toMap() {
Map<String, Object> out = new LinkedHashMap<>(body);
out.put("rule", new LinkedHashMap<>(rule));
return out;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> toMap() {
return new LinkedHashMap<>(body);
}
}
35 changes: 35 additions & 0 deletions src/main/java/com/fopost/sdk/param/UpdatePixelParams.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> toMap() {
return new LinkedHashMap<>(body);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Loading
Loading