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

### Added

- `blog()`: articles for a website. `getSettings`, `updateSettings`, `rotateWebsiteSecret`
and `testWebsite`; `listKeywords`, `planKeywords`, `addKeyword`, `updateKeyword` and
`deleteKeyword`; `listArticles`, `requestArticle`, `getArticle`, `updateArticle`,
`regenerateArticle`, `publishArticle`, `rejectArticle` and `deleteArticle`, with the
`BlogSettings`, `BlogKeyword`, `BlogArticle`, `BlogArticleFull` and `BlogArticleImage`
records and the `BlogArticleStatus`, `BlogKeywordStatus` and `BlogTargetKind` constants.
Reads need `posts`; planning, requesting and regenerating spend AI credits and need `ai`;
publishing needs `publish`.
- `WebhookVerifier.verify` checks a delivery's signature over the raw bytes in constant time,
preferring the timestamped `X-FoPost-Signature-256` within 300 seconds and falling back to
`X-FoPost-Signature` when no timestamp is sent, then returns the parsed `WebhookEnvelope`.
Also reachable as `webhooks().verify`. `WebhookEvents.ARTICLE_PUBLISHED` and the
`BlogArticlePublished` record type its payload.
- `media().limits(workspaceId)` reads `GET /v1/media/limits` into `UploadLimits`: the account's
largest file by any route (100 MB up to 2 connected accounts, 400 MB up to 7, 2 GB up to 15,
5 GB beyond or with the Large Uploads add-on), the per-file multipart ceiling, the next tier,
Expand All @@ -23,14 +36,6 @@ All notable changes to this project are documented here. The format follows
- `PresignUploadParams` takes `folderId`, `origin`, `brandRole`, `brandVoiceId` and
`personConsent`; `PresignedUpload.maxFileBytes` reports the ceiling at reservation time.

### Changed

- 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.

- `InboxItem.moderationStatus` carries the platform's own state for a comment
(`published`, `held`, `spam`, `rejected`), and `InboxAccount.reconnectRequired`
flags an account connected before the inbox asked for a permission it needs.
Expand Down Expand Up @@ -71,6 +76,14 @@ All notable changes to this project are documented here. The format follows
`updateSlackIdentity` (`UpdateSlackIdentityParams`) for a Slack account. A webhook-connected
account answers 409 `webhook_connection`.

### Changed

- 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.

## [0.3.0] - 2026-09-19

### Added
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ src/main/java/com/fopost/sdk/
LabelsResource, WebhooksResource, AnalyticsResource,
AutomationsResource, MediaResource, AiResource, CommunitiesResource,
InboxResource, AdsResource, GoogleAdsResource, ValidateResource,
BroadcastsResource, SequencesResource
BroadcastsResource, SequencesResource, BlogResource
WebhookVerifier.java signature check over the raw bytes; WebhookEnvelope is what it returns
```

**Request flow.** `client.posts().create(params)` → `PostsResource` builds the body and calls
Expand All @@ -81,7 +82,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`, `googleAds`, `validate`. This is
`labels`, `webhooks`, `analytics`, `automations`, `media`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`, `googleAds`, `blog`, `validate`. This is
the most complete of the FoPost SDKs — do not narrow it. `FoPost.request(...)` is the escape hatch for
anything unwrapped.

Expand Down
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole`, `platformMetrics` |
| `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` |
| `labels()` | `list`, `get`, `create`, `update`, `delete` |
| `webhooks()` | `list`, `create`, `update`, `delete`, `test` |
| `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` |
| `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 @@ -256,6 +257,50 @@ System.out.println(hook.secret());
client.webhooks().test(hook.id());
```

## Blog

A post goes to a social network; an article goes to a website. `blog()` holds the keyword plan,
the articles the writer produces, and the settings that say where they are published:
`BlogTargetKind.PRODUCT_BLOG`, a connected `ACCOUNT`, or your own `WEBSITE`, which receives each
published article as a signed webhook.

```java
BlogArticle article = client.blog().requestArticle(
workspace.id(),
BlogParams.Article.create()
.keyword("how to schedule posts")
.instructions("Lead with the calendar view."));

// Poll until the status leaves queued and writing, then read the body.
BlogArticleFull draft = client.blog().getArticle(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
`BlogSettingsUpdate.websiteSecret()`; `rotateWebsiteSecret` issues a new one.

To verify a delivery, hand `WebhookVerifier` the raw request bytes, the headers and the secret.
It prefers the timestamped signature, refuses one more than 300 seconds old, and falls back to
the body-only signature when no timestamp header is present. The same call is on
`client.webhooks().verify(...)`.

```java
byte[] rawBody = request.getInputStream().readAllBytes();
WebhookEnvelope envelope = WebhookVerifier.verify(rawBody, headers, System.getenv("FOPOST_WEBHOOK_SECRET"));

if (WebhookEvents.ARTICLE_PUBLISHED.equals(envelope.event())) {
BlogArticlePublished published = envelope.data(BlogArticlePublished.class);
store(published.slug(), published.bodyHtml());
}
```

A failed check is a `FoPostException` with code `missing_signature`, `invalid_signature`,
`stale_timestamp` or `invalid_payload`. Image URLs in the payload are fetchable for six hours;
download and store your own copy.

## AI features

```java
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 @@ -13,6 +13,7 @@
import com.fopost.sdk.resource.AiResource;
import com.fopost.sdk.resource.AnalyticsResource;
import com.fopost.sdk.resource.AutomationsResource;
import com.fopost.sdk.resource.BlogResource;
import com.fopost.sdk.resource.BroadcastsResource;
import com.fopost.sdk.resource.ContactsResource;
import com.fopost.sdk.resource.InboxResource;
Expand Down Expand Up @@ -82,6 +83,7 @@ public final class FoPost {
private final InboxResource inbox;
private final AdsResource ads;
private final GoogleAdsResource googleAds;
private final BlogResource blog;
private final ValidateResource validate;

private FoPost(ApiClient http) {
Expand All @@ -105,6 +107,7 @@ private FoPost(ApiClient http) {
this.sequences = new SequencesResource(http);
this.ads = new AdsResource(http);
this.googleAds = new GoogleAdsResource(http);
this.blog = new BlogResource(http);
this.validate = new ValidateResource(http);
}

Expand Down Expand Up @@ -209,6 +212,14 @@ public GoogleAdsResource googleAds() {
return googleAds;
}

/**
* Articles for a website: the keyword plan, the drafts the writer produces, and where they
* are published. A post goes to a social network; an article goes to a website.
*/
public BlogResource blog() {
return blog;
}

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

import com.fasterxml.jackson.databind.JsonNode;
import com.fopost.sdk.internal.Json;

/**
* A verified webhook delivery: the event name, its body and when it was sent.
*
* <pre>{@code
* WebhookEnvelope envelope = WebhookVerifier.verify(rawBody, headers, secret);
* if (WebhookEvents.ARTICLE_PUBLISHED.equals(envelope.event())) {
* BlogArticlePublished article = envelope.data(BlogArticlePublished.class);
* }
* }</pre>
*/
public record WebhookEnvelope(String event, JsonNode data, String timestamp) {

/** The body as a typed record, such as {@link com.fopost.sdk.model.BlogArticlePublished}. */
public <T> T data(Class<T> type) {
if (data == null || data.isNull() || data.isMissingNode()) {
return null;
}
return Json.MAPPER.convertValue(data, type);
}
}
164 changes: 164 additions & 0 deletions src/main/java/com/fopost/sdk/WebhookVerifier.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.fopost.sdk;

import com.fasterxml.jackson.databind.JsonNode;
import com.fopost.sdk.internal.Json;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/**
* Verifies the signature on a webhook delivery and hands back the parsed envelope.
*
* <p>Every delivery carries {@code X-FoPost-Timestamp} and {@code X-FoPost-Signature-256}, an
* HMAC-SHA256 over {@code <timestamp>.<rawBody>}; that pair is checked first and refused when the
* timestamp is more than {@link #TOLERANCE} from now. The original {@code X-FoPost-Signature}, over
* the body alone, is accepted only when no timestamp header is present.
*
* <p>Verify the exact bytes that arrived. Re-serialising parsed JSON changes key order, whitespace
* and number formatting, and the signature no longer matches.
*
* <pre>{@code
* byte[] rawBody = request.getInputStream().readAllBytes();
* WebhookEnvelope envelope = WebhookVerifier.verify(rawBody, headers, secret);
* }</pre>
*
* A failed check is a {@link FoPostException} with code {@code missing_signature},
* {@code invalid_signature}, {@code stale_timestamp} or {@code invalid_payload}.
*/
public final class WebhookVerifier {

public static final String SIGNATURE_HEADER = "X-FoPost-Signature";
public static final String TIMESTAMPED_SIGNATURE_HEADER = "X-FoPost-Signature-256";
public static final String TIMESTAMP_HEADER = "X-FoPost-Timestamp";
public static final String EVENT_HEADER = "X-FoPost-Event";
public static final String DELIVERY_HEADER = "X-FoPost-Delivery";

/** How far a delivery's timestamp may sit from the current time. */
public static final Duration TOLERANCE = Duration.ofSeconds(300);

private static final String PREFIX = "sha256=";

private WebhookVerifier() {}

public static WebhookEnvelope verify(byte[] rawBody, Map<String, String> headers, String secret) {
return verify(rawBody, headers, secret, Clock.systemUTC());
}

/** {@code rawBody} is the request body as received, before any parsing. */
public static WebhookEnvelope verify(String rawBody, Map<String, String> headers, String secret) {
return verify(rawBody.getBytes(StandardCharsets.UTF_8), headers, secret);
}

/** As {@link #verify(byte[], Map, String)}, reading the current time from {@code clock}. */
public static WebhookEnvelope verify(byte[] rawBody, Map<String, String> headers, String secret, Clock clock) {
if (rawBody == null || secret == null || secret.isEmpty()) {
throw failure("missing_signature", "fopost: a body and the webhook secret are required");
}
String timestamp = header(headers, TIMESTAMP_HEADER);
if (timestamp != null) {
String signature = header(headers, TIMESTAMPED_SIGNATURE_HEADER);
if (signature == null) {
throw failure("missing_signature", "fopost: " + TIMESTAMPED_SIGNATURE_HEADER + " header is missing");
}
long sent;
try {
sent = Long.parseLong(timestamp.trim());
} catch (NumberFormatException e) {
throw failure("invalid_signature", "fopost: " + TIMESTAMP_HEADER + " is not a unix time");
}
long now = clock.instant().getEpochSecond();
if (Math.abs(now - sent) > TOLERANCE.getSeconds()) {
throw failure("stale_timestamp", "fopost: the delivery is outside the " + TOLERANCE.getSeconds()
+ " second tolerance");
}
if (!matches(signature, sign(rawBody, secret, sent))) {
throw failure("invalid_signature", "fopost: the timestamped signature does not match the body");
}
} else {
String signature = header(headers, SIGNATURE_HEADER);
if (signature == null) {
throw failure("missing_signature", "fopost: " + SIGNATURE_HEADER + " header is missing");
}
if (!matches(signature, sign(rawBody, secret))) {
throw failure("invalid_signature", "fopost: the signature does not match the body");
}
}
return parse(rawBody);
}

/** The {@code X-FoPost-Signature} value the API sends for these bytes. */
public static String sign(byte[] rawBody, String secret) {
return PREFIX + hex(hmac(secret, rawBody));
}

/** The {@code X-FoPost-Signature-256} value the API sends for these bytes at {@code timestamp}. */
public static String sign(byte[] rawBody, String secret, long timestamp) {
byte[] prefix = (timestamp + ".").getBytes(StandardCharsets.UTF_8);
byte[] signed = new byte[prefix.length + rawBody.length];
System.arraycopy(prefix, 0, signed, 0, prefix.length);
System.arraycopy(rawBody, 0, signed, prefix.length, rawBody.length);
return PREFIX + hex(hmac(secret, signed));
}

private static boolean matches(String provided, String expected) {
byte[] left = provided.trim().toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8);
byte[] right = expected.getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(left, right);
}

private static WebhookEnvelope parse(byte[] rawBody) {
JsonNode body;
try {
body = Json.MAPPER.readTree(rawBody);
} catch (IOException e) {
throw failure("invalid_payload", "fopost: the webhook body is not JSON");
}
if (body == null || !body.isObject() || !body.path("event").isTextual()) {
throw failure("invalid_payload", "fopost: the webhook body has no event");
}
return new WebhookEnvelope(
body.get("event").asText(), body.path("data"), body.path("timestamp").asText(null));
}

private static byte[] hmac(String secret, byte[] message) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return mac.doFinal(message);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new FoPostException("fopost: HmacSHA256 is not available", e);
}
}

private static String hex(byte[] bytes) {
StringBuilder out = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
out.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return out.toString();
}

private static String header(Map<String, String> headers, String name) {
if (headers == null) {
return null;
}
for (Map.Entry<String, String> entry : headers.entrySet()) {
if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name) && entry.getValue() != null) {
return entry.getValue();
}
}
return null;
}

private static FoPostException failure(String code, String message) {
return new FoPostException(message, 0, code, null);
}
}
Loading
Loading