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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ All notable changes to this project are documented here. The format follows

### Added

- `blog().suggestSeeds(workspaceId[, seeds, prompt])` suggests about 10 search terms from the
blog goal and topics (needs `ai`, 402 on an empty balance), and `blog().submitArticle` moves a
draft, failed or rejected article to `in_review` (needs `posts`, 409 otherwise).
- `BlogSettings` gains `goal`, `focusTopics`, `avoidTopics` and `planRun` (`BlogPlanRun`:
`status`, `error`, `added`, `finishedAt`); `BlogParams.Settings` gains `goal`, `focusTopics`
and `avoidTopics`, with the `BlogGoal` constants. `BlogKeyword.fitReason` says why a keyword
serves the goal.
- `BlogArticleStatus.IN_REVIEW`.
- `whatsapp()` for a WhatsApp Business connection: the business profile
(`getProfile`, `updateProfile`, `requestDisplayName`, `setUsername`), message
templates including the platform's own library (`listTemplates`,
Expand Down Expand Up @@ -88,12 +96,27 @@ All notable changes to this project are documented here. The format follows

### Changed

- `blog()` matches the live API contract: the workspace travels as `workspace_id` on the query
string, single objects are read bare and lists from `{data}`, and responses are read in
snake_case. `BlogWebsiteTest.status` is now `httpStatus`, read from the API's `http_status`;
a failed test still comes back with `ok` false rather than as an exception.
- `blog().planKeywords` no longer fills the calendar: results land as `suggested` keywords, and
setting one to `planned` with `updateKeyword` picks it and gives it the next free date.
- `blog().publishArticle` and `publishAt` on `updateArticle` need an article in review (or
already scheduled or failed); a draft answers 409 until it is submitted.
- `BlogSettings.reviewHoldDays` is now the number of days before its planned date that a picked
keyword starts being written.
- Presign and direct upload accept sizes up to 5 GB; there is no 50 MB ceiling in the SDK.
- An oversize file is a `413` with code `file_too_large`. It surfaces as a `FoPostException`
with `status()` 413 and `max_file_bytes`, `file_bytes` and `upgrade_url` on `body()`.
- Multipart bodies and direct uploads stream from their source. `HttpRequestData` gained
`bodyStream` and `bodyLength` beside `body`; a custom `Transport` must send whichever is set.

### Removed

- **Breaking:** `BlogSettings.autoPublish` and `BlogParams.Settings.autoPublish(...)`. Every
article now goes through `submitArticle` before it publishes.

## [0.3.0] - 2026-09-19

### Added
Expand Down
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` |
| `labels()` | `list`, `get`, `create`, `update`, `delete` |
| `webhooks()` | `list`, `create`, `update`, `delete`, `test`, `verify` |
| `blog()` | `getSettings`, `updateSettings`, `rotateWebsiteSecret`, `testWebsite`, `listKeywords`, `planKeywords`, `addKeyword`, `updateKeyword`, `deleteKeyword`, `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, `regenerateArticle`, `publishArticle`, `rejectArticle`, `deleteArticle` |
| `blog()` | `getSettings`, `updateSettings`, `rotateWebsiteSecret`, `testWebsite`, `listKeywords`, `suggestSeeds`, `planKeywords`, `addKeyword`, `updateKeyword`, `deleteKeyword`, `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, `regenerateArticle`, `submitArticle`, `publishArticle`, `rejectArticle`, `deleteArticle` |
| `analytics()` | `overview`, `timeSeries`, `topPosts`, `labels`, `postsTable`, `postingStreak`, `demographics`, `collect`, `decay`, `frequency`, `timeline`, `changes`, `collectPost`, `nativePosts` |
| `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats` |
| `media()` | `list`, `limits`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` |
Expand Down Expand Up @@ -266,6 +266,27 @@ the articles the writer produces, and the settings that say where they are publi
`BlogTargetKind.PRODUCT_BLOG`, a connected `ACCOUNT`, or your own `WEBSITE`, which receives each
published article as a signed webhook.

Planning starts from the blog's goal. Suggest search terms, research them, then pick the keywords
worth writing; a picked keyword takes the next free date on the calendar.

```java
client.blog().updateSettings(
workspace.id(),
BlogParams.Settings.create().goal(BlogGoal.LEADS).focusTopics("Scheduling for small teams"));

List<String> seeds = client.blog().suggestSeeds(workspace.id(), List.of(), "Beginners, not agencies");
client.blog().planKeywords(workspace.id(), seeds);

for (BlogKeyword keyword : client.blog().listKeywords(
workspace.id(), BlogParams.Filter.create().status(BlogKeywordStatus.SUGGESTED).limit(3))) {
client.blog().updateKeyword(
workspace.id(), keyword.id(), BlogParams.KeywordUpdate.create().status(BlogKeywordStatus.PLANNED));
}
```

Every article is approved before it publishes: submit a draft for review, then publish it or give
it a `publishAt`.

```java
BlogArticle article = client.blog().requestArticle(
workspace.id(),
Expand All @@ -275,13 +296,14 @@ BlogArticle article = client.blog().requestArticle(

// Poll until the status leaves queued and writing, then read the body.
BlogArticleFull draft = client.blog().getArticle(workspace.id(), article.id());
client.blog().submitArticle(workspace.id(), article.id());
client.blog().publishArticle(workspace.id(), article.id());
```

Reads, settings and keyword edits need `posts`; `planKeywords`, `requestArticle` and
`regenerateArticle` spend AI credits and need `ai` (an empty balance is a
`PaymentRequiredException`); `publishArticle` needs `publish` and answers 409 `no_target` until
a target is set. Setting `websiteUrl` issues the site's signing secret once, on
Reads, settings, keyword edits and `submitArticle` need `posts`; `suggestSeeds`, `planKeywords`,
`requestArticle` and `regenerateArticle` spend AI credits and need `ai` (an empty balance is a
`PaymentRequiredException`); `publishArticle` needs `publish`, answers 409 for a draft that was
never submitted, and 409 `no_target` until a target is set. Setting `websiteUrl` issues the site's signing secret once, on
`BlogSettingsUpdate.websiteSecret()`; `rotateWebsiteSecret` issues a new one.

To verify a delivery, hand `WebhookVerifier` the raw request bytes, the headers and the secret.
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/fopost/sdk/model/BlogArticleStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ private BlogArticleStatus() {}
public static final String QUEUED = "queued";
public static final String WRITING = "writing";
public static final String DRAFT = "draft";
public static final String IN_REVIEW = "in_review";
public static final String SCHEDULED = "scheduled";
public static final String PUBLISHING = "publishing";
public static final String PUBLISHED = "published";
public static final String FAILED = "failed";
public static final String REJECTED = "rejected";

public static final List<String> ALL =
List.of(QUEUED, WRITING, DRAFT, SCHEDULED, PUBLISHING, PUBLISHED, FAILED, REJECTED);
List.of(QUEUED, WRITING, DRAFT, IN_REVIEW, SCHEDULED, PUBLISHING, PUBLISHED, FAILED, REJECTED);
}
17 changes: 17 additions & 0 deletions src/main/java/com/fopost/sdk/model/BlogGoal.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.fopost.sdk.model;

import java.util.List;

/** What the blog is for; steers suggested search terms and keyword ranking. */
public final class BlogGoal {

private BlogGoal() {}

public static final String TRAFFIC = "traffic";
public static final String LEADS = "leads";
public static final String SALES = "sales";
public static final String SIGNUPS = "signups";
public static final String AUTHORITY = "authority";

public static final List<String> ALL = List.of(TRAFFIC, LEADS, SALES, SIGNUPS, AUTHORITY);
}
2 changes: 2 additions & 0 deletions src/main/java/com/fopost/sdk/model/BlogKeyword.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
/**
* A keyword in the plan. {@code status} is one of {@link BlogKeywordStatus}; {@code source} is
* {@code provider}, {@code user} or {@code agent}. {@code difficulty} runs 0 to 100.
* {@code fitReason} says why the keyword serves the blog goal.
*/
public record BlogKeyword(
String id,
Expand All @@ -17,4 +18,5 @@ public record BlogKeyword(
String source,
String status,
LocalDate plannedFor,
String fitReason,
Instant createdAt) {}
9 changes: 9 additions & 0 deletions src/main/java/com/fopost/sdk/model/BlogPlanRun.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.fopost.sdk.model;

import java.time.Instant;

/**
* The last keyword research run. {@code status} is {@code queued}, {@code running}, {@code done} or
* {@code failed}; {@code added} is how many new keywords it found.
*/
public record BlogPlanRun(String status, String error, Integer added, Instant finishedAt) {}
13 changes: 9 additions & 4 deletions src/main/java/com/fopost/sdk/model/BlogSettings.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,26 @@
/**
* A workspace's blog configuration.
*
* <p>{@code cadencePerWeek} is how many articles the planner queues each week; 0 turns planning
* off. {@code reviewHoldDays} is how long a draft waits for review before it may auto-publish.
* {@code targetKind} is one of {@link BlogTargetKind}. {@code hasWebsiteSecret} says whether a
* <p>{@code goal} is one of {@link BlogGoal}, or null. {@code cadencePerWeek} is how many articles
* the planner queues each week; 0 turns planning off. {@code reviewHoldDays} is how many days before
* its planned date a picked keyword starts being written. {@code targetKind} is one of
* {@link BlogTargetKind}. {@code hasWebsiteSecret} says whether a
* website target has a signing secret; the secret itself is only ever returned when issued.
* {@code planRun} is the last keyword research run, null before the first.
* {@code keywordDataAvailable} says whether search volume and difficulty can be fetched on this
* deployment.
*/
public record BlogSettings(
String siteUrl,
String topic,
String audience,
String goal,
String focusTopics,
String avoidTopics,
String language,
Integer locationCode,
int cadencePerWeek,
int reviewHoldDays,
boolean autoPublish,
String imageTier,
Integer inBodyImages,
Integer minWords,
Expand All @@ -31,5 +35,6 @@ public record BlogSettings(
String targetPlatform,
String websiteUrl,
boolean hasWebsiteSecret,
BlogPlanRun planRun,
Boolean keywordDataAvailable,
Instant updatedAt) {}
7 changes: 5 additions & 2 deletions src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
package com.fopost.sdk.model;

/** The outcome of a test delivery to the website target. {@code status} is the site's HTTP answer. */
public record BlogWebsiteTest(boolean ok, Integer status, String error) {}
/**
* The outcome of a test delivery to the website target. {@code httpStatus} is the site's HTTP
* answer; {@code error} says why a failed test failed.
*/
public record BlogWebsiteTest(boolean ok, Integer httpStatus, String error) {}
37 changes: 28 additions & 9 deletions src/main/java/com/fopost/sdk/param/BlogParams.java
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ public Settings audience(String audience) {
return this;
}

/** One of {@link com.fopost.sdk.model.BlogGoal}; null clears it. */
public Settings goal(String goal) {
body.put("goal", goal);
return this;
}

/** What the blog wants to write about; null clears it. */
public Settings focusTopics(String focusTopics) {
body.put("focusTopics", focusTopics);
return this;
}

/** What the blog never writes about; null clears it. */
public Settings avoidTopics(String avoidTopics) {
body.put("avoidTopics", avoidTopics);
return this;
}

public Settings language(String language) {
body.put("language", language);
return this;
Expand All @@ -71,17 +89,12 @@ public Settings cadencePerWeek(int cadencePerWeek) {
return this;
}

/** Days a draft waits for review before it may auto-publish, 0 to 14. */
/** Days before its planned date that a picked keyword starts being written, 0 to 14. */
public Settings reviewHoldDays(int reviewHoldDays) {
body.put("reviewHoldDays", reviewHoldDays);
return this;
}

public Settings autoPublish(boolean autoPublish) {
body.put("autoPublish", autoPublish);
return this;
}

/** {@code draft}, {@code standard} or {@code best}. */
public Settings imageTier(String imageTier) {
body.put("imageTier", imageTier);
Expand Down Expand Up @@ -191,7 +204,10 @@ public static KeywordUpdate create() {
return new KeywordUpdate();
}

/** One of {@link com.fopost.sdk.model.BlogKeywordStatus}. */
/**
* One of {@link com.fopost.sdk.model.BlogKeywordStatus}. {@code planned} picks the keyword and
* gives it the next free date.
*/
public KeywordUpdate status(String status) {
body.put("status", status);
return this;
Expand Down Expand Up @@ -244,7 +260,7 @@ public Map<String, Object> toMap() {
}
}

/** Edits a draft. Only a draft or scheduled article is editable; anything else answers 409. */
/** Edits an article. One still being written or already publishing answers 409. */
public static final class ArticleUpdate {

private final Map<String, Object> body = new LinkedHashMap<>();
Expand Down Expand Up @@ -289,7 +305,10 @@ public ArticleUpdate tags(List<String> tags) {
return this;
}

/** Puts the draft on the calendar; null returns it to review. */
/**
* Puts an article in review on the calendar; a draft answers 409 until it is submitted. Null
* returns it to draft.
*/
public ArticleUpdate publishAt(Instant publishAt) {
body.put("publishAt", Params.iso(publishAt));
return this;
Expand Down
Loading
Loading