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
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ src/main/java/com/fopost/sdk/
resource/ PostsResource, AccountsResource (+ .communities()), WorkspacesResource,
LabelsResource, WebhooksResource, AnalyticsResource,
AutomationsResource, MediaResource, AiResource, CommunitiesResource,
InboxResource, AdsResource, ValidateResource,
InboxResource, AdsResource, GoogleAdsResource, ValidateResource,
BroadcastsResource, SequencesResource
```

Expand All @@ -81,7 +81,7 @@ calls `ApiClient.unwrap(...)` and `convert(...)`/`convertList(...)` into a recor
- `FoPost` instances are immutable and safe to share across threads.

**Resources wired today:** `posts`, `accounts` (with `accounts().communities()`), `workspaces`,
`labels`, `webhooks`, `analytics`, `automations`, `media`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`, `validate`. This is
`labels`, `webhooks`, `analytics`, `automations`, `media`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`, `googleAds`, `validate`. This is
the most complete of the FoPost SDKs — do not narrow it. `FoPost.request(...)` is the escape hatch for
anything unwrapped.

Expand All @@ -107,6 +107,18 @@ anything unwrapped.
- `ads` (scope `ads`) covers `/v1/ads/*`. Request bodies are camelCase; `boost`, `create`,
`setStatus` and `delete` also need the `publish` scope, and a boost or ad starts paused unless
`paused` is false. Say both in the javadoc of anything new that spends.
- `googleAds` (scope `ads`) covers `/v1/ads/google/*` plus the GAQL passthrough at
`POST /v1/ads/insights/query`: recommendations, the optimization score, keywords, keyword
ideas, search terms, bid strategies, the ad schedule, negative keyword lists, assets,
Performance Max asset groups, Local Services leads and conversions. A connection on another
network answers 400, so this is the one resource that is not network-agnostic. Campaigns, ad
groups, ads, audiences, insights, labels, change history, experiments and conversion value
rules are all on `ads` and work on Google through the shared routes — do not duplicate them
here. `GoogleAdsScope` carries the connection and the customer, and renders snake_case on a
query and camelCase in a body, because the API takes it both ways. An object id is
`<customerId>~<kind>~<id>`: a Google resource name has slashes and cannot ride in a path
segment. Applying a recommendation changes what the live account serves or bids, so it needs
`publish`; dismissing one only hides it.

## API Contract

Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| `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`, `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` |
| `googleAds()` | `recommendations`, `optimizationScore`, `applyRecommendations`, `dismissRecommendations`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`, `keywordIdeas`, `keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`, `setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`, `attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`, `assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`, `localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`, `uploadConversionAdjustments`, `query` |
| `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` |
Expand Down Expand Up @@ -324,6 +325,44 @@ 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.

### Google Ads

Campaigns, ad groups, ads, audiences, insights, labels, change history, experiments and
conversion value rules are all on `ads()` and dispatch by connection, so they work on Google
through the same calls as any other network. What only Google has is on `googleAds()`:

```java
var scope = GoogleAdsScope.of(connectionId, "1234567890");

for (var recommendation : client.googleAds().recommendations(scope, List.of("KEYWORD"))) {
System.out.println(recommendation.type() + " -> " + recommendation.impact().potentialClicks());
}

client.googleAds().applyRecommendations(
GoogleAdsScope.of(connectionId, "1234567890").workspace(workspace.id()),
List.of("customers/1234567890/recommendations/ABC~1"));

var ideas = client.googleAds().keywordIdeas(
new GoogleKeywordIdeasParams(scope).seeds(List.of("running shoes")));
```

Also here: `optimizationScore`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`,
`keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`,
`setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`,
`attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`,
`assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`,
`localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`,
`uploadConversionAdjustments`, and `query` for a raw read-only GAQL SELECT.

An object id is `<customerId>~<kind>~<id>` — a Google resource name has slashes and cannot ride
in a URL path segment, so every id carries the account it belongs to. The customer has to be an
account the connection's grant reaches; any other answers 404. Amounts are in the account's
currency, in minor units.

Applying a recommendation changes what the live account serves or bids straight away, so it
needs `publish` as well as `ads`; dismissing one only hides it. A connection on another network
answers 400 on every call here.

## Validation

Check a draft, a text, or a media url against the platform rules before creating anything:
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/fopost/sdk/FoPost.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.fopost.sdk.resource.ContactsResource;
import com.fopost.sdk.resource.InboxResource;
import com.fopost.sdk.resource.KnowledgeResource;
import com.fopost.sdk.resource.GoogleAdsResource;
import com.fopost.sdk.resource.GoogleBusinessResource;
import com.fopost.sdk.resource.LabelsResource;
import com.fopost.sdk.resource.MediaResource;
Expand Down Expand Up @@ -80,6 +81,7 @@ public final class FoPost {
private final GoogleBusinessResource googleBusiness;
private final InboxResource inbox;
private final AdsResource ads;
private final GoogleAdsResource googleAds;
private final ValidateResource validate;

private FoPost(ApiClient http) {
Expand All @@ -102,6 +104,7 @@ private FoPost(ApiClient http) {
this.broadcasts = new BroadcastsResource(http);
this.sequences = new SequencesResource(http);
this.ads = new AdsResource(http);
this.googleAds = new GoogleAdsResource(http);
this.validate = new ValidateResource(http);
}

Expand Down Expand Up @@ -198,6 +201,14 @@ public AdsResource ads() {
return ads;
}

/**
* The Google Ads surface no other network has. Campaigns, ad groups, ads, audiences and
* insights are on {@link #ads()} and dispatch by connection.
*/
public GoogleAdsResource googleAds() {
return googleAds;
}

public ValidateResource validate() {
return validate;
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleAdScheduleSlot.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.fopost.sdk.model;

/** One slot of a campaign's ad schedule. */
public record GoogleAdScheduleSlot(
String id, String dayOfWeek, int startHour, int endHour, Double bidModifier) {}
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleAsset.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** A sitelink, callout, or structured snippet. */
public record GoogleAsset(String id, String name, String type, String text, String finalUrl) {}
7 changes: 7 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleAssetGroup.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.fopost.sdk.model;

import java.util.List;

/** A Performance Max asset group. */
public record GoogleAssetGroup(
String id, String campaignId, String name, String status, List<String> finalUrls) {}
5 changes: 5 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleAssetLink.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.fopost.sdk.model;

/** Where an asset is attached. An asset with no links serves nowhere. */
public record GoogleAssetLink(
String id, String assetId, String level, String ownerId, String fieldType, String status) {}
6 changes: 6 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleAssetsResult.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 account's assets with the links that place each one. */
public record GoogleAssetsResult(List<GoogleAsset> assets, List<GoogleAssetLink> links) {}
5 changes: 5 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleBidStrategy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.fopost.sdk.model;

/** A portfolio bid strategy on the account. */
public record GoogleBidStrategy(
String id, String name, String type, String status, int campaignCount) {}
12 changes: 12 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleConversionAction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.fopost.sdk.model;

/** A conversion action on the account. */
public record GoogleConversionAction(
String id,
String name,
String category,
String status,
String type,
String countingType,
/** The account's currency, in minor units. */
Long valueMinor) {}
18 changes: 18 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleKeyword.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.fopost.sdk.model;

/**
* A keyword on an ad group.
*
* <p>{@code id} is {@code <customerId>~keyword~<adGroupId>~<criterionId>}: a Google resource name
* has slashes and cannot ride in a URL path segment, so every id here carries the account it
* belongs to.
*/
public record GoogleKeyword(
String id,
String adGroupId,
String text,
String matchType,
String status,
/** The account's currency, in minor units. */
Long cpcBidMinor,
boolean negative) {}
10 changes: 10 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleKeywordIdea.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.fopost.sdk.model;

/** A keyword idea, or the historical metrics of a keyword you already have. */
public record GoogleKeywordIdea(
String text,
long avgMonthlySearches,
String competition,
/** The account's currency, in minor units. */
Long lowTopOfPageBidMinor,
Long highTopOfPageBidMinor) {}
13 changes: 13 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleLocalServicesLead.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.fopost.sdk.model;

/** A lead from Local Services Ads, read live and never stored. */
public record GoogleLocalServicesLead(
String id,
String category,
String service,
String contactName,
String phone,
String email,
String status,
String type,
String createdAt) {}
10 changes: 10 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleOptimizationScore.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.fopost.sdk.model;

import java.util.List;

/** Google's estimate of how well the account is set up, from 0 to 1. */
public record GoogleOptimizationScore(
Double score,
/** How much this account's score counts against others under the same manager. */
Double weight,
List<GoogleOptimizationScoreCampaign> campaigns) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** One campaign's optimization score. */
public record GoogleOptimizationScoreCampaign(String id, String name, Double score) {}
7 changes: 7 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleQueryResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.fopost.sdk.model;

import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;

/** Rows exactly as Google returns them. */
public record GoogleQueryResult(List<JsonNode> rows) {}
15 changes: 15 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleRecommendation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.fopost.sdk.model;

/**
* One of Google's own recommendations for the account.
*
* <p>{@code id} is the Google resource name rather than the {@code ~} form other objects use,
* because a recommendation is not an object you address again: it is what apply and dismiss take.
*/
public record GoogleRecommendation(
String id,
String type,
String campaignId,
String adGroupId,
boolean dismissed,
GoogleRecommendationImpact impact) {}
14 changes: 14 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleRecommendationImpact.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.fopost.sdk.model;

/**
* What Google projects applying a recommendation would change. A null field is one Google does
* not estimate for that recommendation.
*/
public record GoogleRecommendationImpact(
Double baseClicks,
Double potentialClicks,
/** The account's currency, in minor units. */
Long baseCostMinor,
Long potentialCostMinor,
Double baseConversions,
Double potentialConversions) {}
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleSearchTerm.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** What someone actually searched, with the metrics it earned. */
public record GoogleSearchTerm(String term, String adGroupId, String status, AdInsights metrics) {}
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/GoogleSharedSet.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** A negative keyword list. */
public record GoogleSharedSet(String id, String name, String type, int memberCount) {}
44 changes: 44 additions & 0 deletions src/main/java/com/fopost/sdk/param/GoogleAdScheduleParams.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.fopost.sdk.param;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* Replace a campaign's ad schedule. Google has no partial edit for one, so every slot the
* campaign should keep has to be here.
*/
public final class GoogleAdScheduleParams {

private final Map<String, Object> body;
private final List<Map<String, Object>> slots = new ArrayList<>();

public GoogleAdScheduleParams(GoogleAdsScope scope, String campaignId) {
body = scope.toMap();
body.put("campaignId", campaignId);
}

/** {@code dayOfWeek} is MONDAY through SUNDAY; the hours are 0 to 24. */
public GoogleAdScheduleParams slot(String dayOfWeek, int startHour, int endHour) {
return slot(dayOfWeek, startHour, endHour, null);
}

public GoogleAdScheduleParams slot(
String dayOfWeek, int startHour, int endHour, Double bidModifier) {
Map<String, Object> slot = new LinkedHashMap<>();
slot.put("dayOfWeek", dayOfWeek);
slot.put("startHour", startHour);
slot.put("endHour", endHour);
if (bidModifier != null) {
slot.put("bidModifier", bidModifier);
}
slots.add(slot);
return this;
}

public Map<String, Object> toMap() {
body.put("slots", slots);
return body;
}
}
54 changes: 54 additions & 0 deletions src/main/java/com/fopost/sdk/param/GoogleAdsScope.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.fopost.sdk.param;

import java.util.LinkedHashMap;
import java.util.Map;

/**
* The connection and the Google Ads account a call runs against.
*
* <p>{@code customerId} is digits only and has to name an account the connection's grant reaches:
* any other answers 404. The workspace may be left out on a read, and is required on a write.
*/
public final class GoogleAdsScope {

private final String connectionId;
private final String customerId;
private String workspaceId;

private GoogleAdsScope(String connectionId, String customerId) {
this.connectionId = connectionId;
this.customerId = customerId;
}

public static GoogleAdsScope of(String connectionId, String customerId) {
return new GoogleAdsScope(connectionId, customerId);
}

/** A write names the workspace the connection lives in. */
public GoogleAdsScope workspace(String workspaceId) {
this.workspaceId = workspaceId;
return this;
}

/** Snake_case, the way a read takes it on the query string. */
public Map<String, Object> toQuery() {
Map<String, Object> query = new LinkedHashMap<>();
if (workspaceId != null) {
query.put("workspace_id", workspaceId);
}
query.put("connection_id", connectionId);
query.put("customer_id", customerId);
return query;
}

/** CamelCase, the way a write takes it in the body. */
public Map<String, Object> toMap() {
Map<String, Object> body = new LinkedHashMap<>();
if (workspaceId != null) {
body.put("workspaceId", workspaceId);
}
body.put("connectionId", connectionId);
body.put("customerId", customerId);
return body;
}
}
Loading
Loading