type) {
+ if (data == null || data.isNull() || data.isMissingNode()) {
+ return null;
+ }
+ return Json.MAPPER.convertValue(data, type);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/WebhookVerifier.java b/src/main/java/com/fopost/sdk/WebhookVerifier.java
new file mode 100644
index 0000000..f732c8f
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/WebhookVerifier.java
@@ -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.
+ *
+ * Every delivery carries {@code X-FoPost-Timestamp} and {@code X-FoPost-Signature-256}, an
+ * HMAC-SHA256 over {@code .}; 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.
+ *
+ * Verify the exact bytes that arrived. Re-serialising parsed JSON changes key order, whitespace
+ * and number formatting, and the signature no longer matches.
+ *
+ *
{@code
+ * byte[] rawBody = request.getInputStream().readAllBytes();
+ * WebhookEnvelope envelope = WebhookVerifier.verify(rawBody, headers, secret);
+ * }
+ *
+ * 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 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 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 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 headers, String name) {
+ if (headers == null) {
+ return null;
+ }
+ for (Map.Entry 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);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/model/BlogArticle.java b/src/main/java/com/fopost/sdk/model/BlogArticle.java
new file mode 100644
index 0000000..b6ee92c
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogArticle.java
@@ -0,0 +1,32 @@
+package com.fopost.sdk.model;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * An article as a listing shows it. {@code status} is one of {@link BlogArticleStatus};
+ * {@code origin} is {@code plan}, {@code user} or {@code agent}. {@code reviewUntil} is when a
+ * draft may auto-publish, {@code publishAt} where it sits on the calendar, and {@code error} the
+ * reason the last write or publish failed. {@link com.fopost.sdk.resource.BlogResource#getArticle}
+ * returns the body as well, as a {@link BlogArticleFull}.
+ */
+public record BlogArticle(
+ String id,
+ String title,
+ String slug,
+ String keyword,
+ String excerpt,
+ String metaTitle,
+ String metaDescription,
+ BlogArticleImage featuredImage,
+ List tags,
+ int wordCount,
+ String status,
+ String origin,
+ Instant reviewUntil,
+ Instant publishAt,
+ Instant publishedAt,
+ String publishedUrl,
+ String error,
+ Instant createdAt,
+ Instant updatedAt) {}
diff --git a/src/main/java/com/fopost/sdk/model/BlogArticleFull.java b/src/main/java/com/fopost/sdk/model/BlogArticleFull.java
new file mode 100644
index 0000000..61c7462
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogArticleFull.java
@@ -0,0 +1,37 @@
+package com.fopost.sdk.model;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * An article with its body: every field of {@link BlogArticle} plus the instructions it was
+ * written from, the outline, the research brief, both renderings of the body and the in-body
+ * images.
+ */
+public record BlogArticleFull(
+ String id,
+ String title,
+ String slug,
+ String keyword,
+ String excerpt,
+ String metaTitle,
+ String metaDescription,
+ BlogArticleImage featuredImage,
+ List tags,
+ int wordCount,
+ String status,
+ String origin,
+ Instant reviewUntil,
+ Instant publishAt,
+ Instant publishedAt,
+ String publishedUrl,
+ String error,
+ Instant createdAt,
+ Instant updatedAt,
+ String instructions,
+ List outline,
+ JsonNode brief,
+ String bodyMarkdown,
+ String bodyHtml,
+ List images) {}
diff --git a/src/main/java/com/fopost/sdk/model/BlogArticleImage.java b/src/main/java/com/fopost/sdk/model/BlogArticleImage.java
new file mode 100644
index 0000000..f38c810
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogArticleImage.java
@@ -0,0 +1,8 @@
+package com.fopost.sdk.model;
+
+/**
+ * An image on an article. {@code position} is the index of the outline heading the image sits
+ * under, and is absent on the featured image. A URL in a webhook payload is fetchable for six
+ * hours; download and store your own copy.
+ */
+public record BlogArticleImage(String mediaId, String url, String alt, Integer position) {}
diff --git a/src/main/java/com/fopost/sdk/model/BlogArticlePublished.java b/src/main/java/com/fopost/sdk/model/BlogArticlePublished.java
new file mode 100644
index 0000000..01fb267
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogArticlePublished.java
@@ -0,0 +1,25 @@
+package com.fopost.sdk.model;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * The {@code data} of an {@code article.published} webhook, and of the delivery a website target
+ * receives. Image URLs are fetchable for six hours; download and store your own copy.
+ */
+public record BlogArticlePublished(
+ String id,
+ String title,
+ String slug,
+ String excerpt,
+ String metaTitle,
+ String metaDescription,
+ String bodyMarkdown,
+ String bodyHtml,
+ BlogArticleImage featuredImage,
+ List images,
+ List tags,
+ String keyword,
+ int wordCount,
+ Instant publishedAt,
+ String publishedUrl) {}
diff --git a/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java b/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java
new file mode 100644
index 0000000..c9fc2d9
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java
@@ -0,0 +1,21 @@
+package com.fopost.sdk.model;
+
+import java.util.List;
+
+/** The statuses an article moves through, from the request to the live page. */
+public final class BlogArticleStatus {
+
+ private BlogArticleStatus() {}
+
+ public static final String QUEUED = "queued";
+ public static final String WRITING = "writing";
+ public static final String DRAFT = "draft";
+ 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 ALL =
+ List.of(QUEUED, WRITING, DRAFT, SCHEDULED, PUBLISHING, PUBLISHED, FAILED, REJECTED);
+}
diff --git a/src/main/java/com/fopost/sdk/model/BlogKeyword.java b/src/main/java/com/fopost/sdk/model/BlogKeyword.java
new file mode 100644
index 0000000..9c3feaf
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogKeyword.java
@@ -0,0 +1,20 @@
+package com.fopost.sdk.model;
+
+import java.time.Instant;
+import java.time.LocalDate;
+
+/**
+ * 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.
+ */
+public record BlogKeyword(
+ String id,
+ String keyword,
+ Integer searchVolume,
+ Integer difficulty,
+ Double cpc,
+ String intent,
+ String source,
+ String status,
+ LocalDate plannedFor,
+ Instant createdAt) {}
diff --git a/src/main/java/com/fopost/sdk/model/BlogKeywordStatus.java b/src/main/java/com/fopost/sdk/model/BlogKeywordStatus.java
new file mode 100644
index 0000000..5a558cc
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogKeywordStatus.java
@@ -0,0 +1,16 @@
+package com.fopost.sdk.model;
+
+import java.util.List;
+
+/** Where a keyword sits in the plan. */
+public final class BlogKeywordStatus {
+
+ private BlogKeywordStatus() {}
+
+ public static final String SUGGESTED = "suggested";
+ public static final String PLANNED = "planned";
+ public static final String WRITTEN = "written";
+ public static final String DISMISSED = "dismissed";
+
+ public static final List ALL = List.of(SUGGESTED, PLANNED, WRITTEN, DISMISSED);
+}
diff --git a/src/main/java/com/fopost/sdk/model/BlogSettings.java b/src/main/java/com/fopost/sdk/model/BlogSettings.java
new file mode 100644
index 0000000..6dedc11
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogSettings.java
@@ -0,0 +1,35 @@
+package com.fopost.sdk.model;
+
+import java.time.Instant;
+
+/**
+ * A workspace's blog configuration.
+ *
+ * {@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
+ * website target has a signing secret; the secret itself is only ever returned when issued.
+ * {@code keywordDataAvailable} says whether search volume and difficulty can be fetched on this
+ * deployment.
+ */
+public record BlogSettings(
+ String siteUrl,
+ String topic,
+ String audience,
+ String language,
+ Integer locationCode,
+ int cadencePerWeek,
+ int reviewHoldDays,
+ boolean autoPublish,
+ String imageTier,
+ Integer inBodyImages,
+ Integer minWords,
+ Integer maxWords,
+ String brandVoiceId,
+ String targetKind,
+ String targetAccountId,
+ String targetPlatform,
+ String websiteUrl,
+ boolean hasWebsiteSecret,
+ Boolean keywordDataAvailable,
+ Instant updatedAt) {}
diff --git a/src/main/java/com/fopost/sdk/model/BlogSettingsUpdate.java b/src/main/java/com/fopost/sdk/model/BlogSettingsUpdate.java
new file mode 100644
index 0000000..9987e8a
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogSettingsUpdate.java
@@ -0,0 +1,7 @@
+package com.fopost.sdk.model;
+
+/**
+ * The answer to a settings update. {@code websiteSecret} is set only when the update set
+ * {@code websiteUrl} for the first time, and is never shown again: store it then.
+ */
+public record BlogSettingsUpdate(BlogSettings settings, String websiteSecret) {}
diff --git a/src/main/java/com/fopost/sdk/model/BlogTargetKind.java b/src/main/java/com/fopost/sdk/model/BlogTargetKind.java
new file mode 100644
index 0000000..02c1435
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogTargetKind.java
@@ -0,0 +1,15 @@
+package com.fopost.sdk.model;
+
+import java.util.List;
+
+/** Where published articles go: the hosted product blog, a connected account, or your own site. */
+public final class BlogTargetKind {
+
+ private BlogTargetKind() {}
+
+ public static final String PRODUCT_BLOG = "product_blog";
+ public static final String ACCOUNT = "account";
+ public static final String WEBSITE = "website";
+
+ public static final List ALL = List.of(PRODUCT_BLOG, ACCOUNT, WEBSITE);
+}
diff --git a/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java b/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java
new file mode 100644
index 0000000..f745c86
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java
@@ -0,0 +1,4 @@
+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) {}
diff --git a/src/main/java/com/fopost/sdk/model/WebhookEvents.java b/src/main/java/com/fopost/sdk/model/WebhookEvents.java
index 86552d4..959bbbd 100644
--- a/src/main/java/com/fopost/sdk/model/WebhookEvents.java
+++ b/src/main/java/com/fopost/sdk/model/WebhookEvents.java
@@ -14,8 +14,9 @@ private WebhookEvents() {}
public static final String DELIVERY_FAILED = "delivery.failed";
public static final String DELIVERY_DELAYED = "delivery.delayed";
public static final String ACCOUNT_HEALTH_CHANGED = "account.health_changed";
+ public static final String ARTICLE_PUBLISHED = "article.published";
public static final List ALL = List.of(
POST_PUBLISHED, POST_FAILED, POST_PARTIALLY_FAILED, DELIVERY_PUBLISHED,
- DELIVERY_FAILED, DELIVERY_DELAYED, ACCOUNT_HEALTH_CHANGED);
+ DELIVERY_FAILED, DELIVERY_DELAYED, ACCOUNT_HEALTH_CHANGED, ARTICLE_PUBLISHED);
}
diff --git a/src/main/java/com/fopost/sdk/param/BlogParams.java b/src/main/java/com/fopost/sdk/param/BlogParams.java
new file mode 100644
index 0000000..b779bc7
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/BlogParams.java
@@ -0,0 +1,302 @@
+package com.fopost.sdk.param;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fopost.sdk.internal.Json;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Request builders for {@link com.fopost.sdk.resource.BlogResource}. Bodies are camelCase on the
+ * wire, and a field set to null is sent as null so the API clears it.
+ */
+public final class BlogParams {
+
+ private BlogParams() {}
+
+ // A plain Map would lose its nulls: the mapper is NON_NULL, and a cleared field must reach
+ // the API as null.
+ static ObjectNode node(Map values) {
+ ObjectNode node = Json.MAPPER.createObjectNode();
+ values.forEach((key, value) -> {
+ if (value == null) {
+ node.putNull(key);
+ } else {
+ node.set(key, Json.MAPPER.valueToTree(value));
+ }
+ });
+ return node;
+ }
+
+ /** A partial update of the workspace's blog settings; only what is set is sent. */
+ public static final class Settings {
+
+ private final Map body = new LinkedHashMap<>();
+
+ public static Settings create() {
+ return new Settings();
+ }
+
+ /** The site the articles are written for; null clears it. */
+ public Settings siteUrl(String siteUrl) {
+ body.put("siteUrl", siteUrl);
+ return this;
+ }
+
+ public Settings topic(String topic) {
+ body.put("topic", topic);
+ return this;
+ }
+
+ public Settings audience(String audience) {
+ body.put("audience", audience);
+ return this;
+ }
+
+ public Settings language(String language) {
+ body.put("language", language);
+ return this;
+ }
+
+ public Settings locationCode(int locationCode) {
+ body.put("locationCode", locationCode);
+ return this;
+ }
+
+ /** Articles planned per week, 0 to 7; 0 turns planning off. */
+ public Settings cadencePerWeek(int cadencePerWeek) {
+ body.put("cadencePerWeek", cadencePerWeek);
+ return this;
+ }
+
+ /** Days a draft waits for review before it may auto-publish, 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);
+ return this;
+ }
+
+ /** Images placed in the body, 0 to 3. */
+ public Settings inBodyImages(int inBodyImages) {
+ body.put("inBodyImages", inBodyImages);
+ return this;
+ }
+
+ public Settings minWords(int minWords) {
+ body.put("minWords", minWords);
+ return this;
+ }
+
+ public Settings maxWords(int maxWords) {
+ body.put("maxWords", maxWords);
+ return this;
+ }
+
+ /** The brand the writer takes its voice from; null returns to the workspace default. */
+ public Settings brandVoiceId(String brandVoiceId) {
+ body.put("brandVoiceId", brandVoiceId);
+ return this;
+ }
+
+ /** One of {@link com.fopost.sdk.model.BlogTargetKind}; null leaves the blog with no target. */
+ public Settings targetKind(String targetKind) {
+ body.put("targetKind", targetKind);
+ return this;
+ }
+
+ public Settings targetAccountId(String targetAccountId) {
+ body.put("targetAccountId", targetAccountId);
+ return this;
+ }
+
+ /**
+ * Where published articles are POSTed as a signed webhook. Setting it for the first time
+ * issues a secret, returned once on {@link com.fopost.sdk.model.BlogSettingsUpdate}.
+ */
+ public Settings websiteUrl(String websiteUrl) {
+ body.put("websiteUrl", websiteUrl);
+ return this;
+ }
+
+ public ObjectNode toNode() {
+ return node(body);
+ }
+ }
+
+ /** Filters for a keyword or article listing. */
+ public static final class Filter {
+
+ private final Map query = new LinkedHashMap<>();
+
+ public static Filter create() {
+ return new Filter();
+ }
+
+ /** One or more statuses; several are joined with a comma. */
+ public Filter status(String... statuses) {
+ query.put("status", String.join(",", statuses));
+ return this;
+ }
+
+ public Filter limit(int limit) {
+ query.put("limit", limit);
+ return this;
+ }
+
+ public Map toQuery() {
+ return new LinkedHashMap<>(query);
+ }
+ }
+
+ /** A keyword added to the plan by hand. */
+ public static final class Keyword {
+
+ private final Map body = new LinkedHashMap<>();
+
+ public static Keyword of(String keyword) {
+ Keyword params = new Keyword();
+ params.body.put("keyword", keyword);
+ return params;
+ }
+
+ /** The day the article for it should be written. */
+ public Keyword plannedFor(LocalDate plannedFor) {
+ Params.put(body, "plannedFor", plannedFor == null ? null : plannedFor.toString());
+ return this;
+ }
+
+ public Map toMap() {
+ return new LinkedHashMap<>(body);
+ }
+ }
+
+ /** Moves a keyword in the plan or re-dates it. */
+ public static final class KeywordUpdate {
+
+ private final Map body = new LinkedHashMap<>();
+
+ public static KeywordUpdate create() {
+ return new KeywordUpdate();
+ }
+
+ /** One of {@link com.fopost.sdk.model.BlogKeywordStatus}. */
+ public KeywordUpdate status(String status) {
+ body.put("status", status);
+ return this;
+ }
+
+ /** The day the article for it should be written; null takes it off the calendar. */
+ public KeywordUpdate plannedFor(LocalDate plannedFor) {
+ body.put("plannedFor", plannedFor == null ? null : plannedFor.toString());
+ return this;
+ }
+
+ public ObjectNode toNode() {
+ return node(body);
+ }
+ }
+
+ /** Asks the writer for an article. One of {@code title}, {@code keyword} or {@code keywordId} is required. */
+ public static final class Article {
+
+ private final Map body = new LinkedHashMap<>();
+
+ public static Article create() {
+ return new Article();
+ }
+
+ public Article title(String title) {
+ body.put("title", title);
+ return this;
+ }
+
+ public Article keyword(String keyword) {
+ body.put("keyword", keyword);
+ return this;
+ }
+
+ /** A keyword from the plan; its term becomes the target. */
+ public Article keywordId(String keywordId) {
+ body.put("keywordId", keywordId);
+ return this;
+ }
+
+ /** Up to 4000 characters of direction for the writer. */
+ public Article instructions(String instructions) {
+ body.put("instructions", instructions);
+ return this;
+ }
+
+ public Map toMap() {
+ return new LinkedHashMap<>(body);
+ }
+ }
+
+ /** Edits a draft. Only a draft or scheduled article is editable; anything else answers 409. */
+ public static final class ArticleUpdate {
+
+ private final Map body = new LinkedHashMap<>();
+
+ public static ArticleUpdate create() {
+ return new ArticleUpdate();
+ }
+
+ public ArticleUpdate title(String title) {
+ body.put("title", title);
+ return this;
+ }
+
+ /** Lowercase letters, digits and hyphens. */
+ public ArticleUpdate slug(String slug) {
+ body.put("slug", slug);
+ return this;
+ }
+
+ public ArticleUpdate excerpt(String excerpt) {
+ body.put("excerpt", excerpt);
+ return this;
+ }
+
+ public ArticleUpdate metaTitle(String metaTitle) {
+ body.put("metaTitle", metaTitle);
+ return this;
+ }
+
+ public ArticleUpdate metaDescription(String metaDescription) {
+ body.put("metaDescription", metaDescription);
+ return this;
+ }
+
+ public ArticleUpdate bodyMarkdown(String bodyMarkdown) {
+ body.put("bodyMarkdown", bodyMarkdown);
+ return this;
+ }
+
+ public ArticleUpdate tags(List tags) {
+ body.put("tags", tags);
+ return this;
+ }
+
+ /** Puts the draft on the calendar; null returns it to review. */
+ public ArticleUpdate publishAt(Instant publishAt) {
+ body.put("publishAt", Params.iso(publishAt));
+ return this;
+ }
+
+ public ObjectNode toNode() {
+ return node(body);
+ }
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/resource/BlogResource.java b/src/main/java/com/fopost/sdk/resource/BlogResource.java
new file mode 100644
index 0000000..f49d25e
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/resource/BlogResource.java
@@ -0,0 +1,207 @@
+package com.fopost.sdk.resource;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fopost.sdk.FoPostException;
+import com.fopost.sdk.internal.ApiClient;
+import com.fopost.sdk.internal.Json;
+import com.fopost.sdk.model.BlogArticle;
+import com.fopost.sdk.model.BlogArticleFull;
+import com.fopost.sdk.model.BlogKeyword;
+import com.fopost.sdk.model.BlogSettings;
+import com.fopost.sdk.model.BlogSettingsUpdate;
+import com.fopost.sdk.model.BlogWebsiteTest;
+import com.fopost.sdk.param.BlogParams;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 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.
+ *
+ * Reads, settings and keyword edits need the {@code posts} scope. {@code planKeywords},
+ * {@code requestArticle} and {@code regenerateArticle} spend AI credits and need {@code ai}; an
+ * empty balance answers 402. {@code publishArticle} needs {@code publish} and answers 409
+ * {@code no_target} until the settings name a target. Every call names the workspace: on the query
+ * string for a read, an update or a delete, in the body for a create.
+ *
+ *
{@code
+ * BlogArticle article = fopost.blog().requestArticle(
+ * workspace.id(), BlogParams.Article.create().keyword("how to schedule posts"));
+ *
+ * BlogArticleFull full = fopost.blog().getArticle(workspace.id(), article.id());
+ * fopost.blog().publishArticle(workspace.id(), article.id());
+ * }
+ */
+public final class BlogResource {
+
+ private final ApiClient http;
+
+ public BlogResource(ApiClient http) {
+ this.http = http;
+ }
+
+ // ── Settings ──
+
+ public BlogSettings getSettings(String workspaceId) {
+ return http.convert(
+ ApiClient.unwrap(http.get("/v1/blog/settings", workspace(workspaceId))), BlogSettings.class);
+ }
+
+ /**
+ * Change the settings. Setting {@code websiteUrl} for the first time issues the signing secret
+ * for that site; it comes back once on {@link BlogSettingsUpdate#websiteSecret()} and is never
+ * shown again. Rotate it with {@link #rotateWebsiteSecret}.
+ */
+ public BlogSettingsUpdate updateSettings(String workspaceId, BlogParams.Settings params) {
+ JsonNode body = http.patch("/v1/blog/settings", withWorkspace(workspaceId, params.toNode()));
+ BlogSettings settings = http.convert(ApiClient.unwrap(body), BlogSettings.class);
+ JsonNode secret = body.path("websiteSecret");
+ return new BlogSettingsUpdate(settings, secret.isTextual() ? secret.asText() : null);
+ }
+
+ /** Issue a new website signing secret. The old one stops verifying at once. */
+ public String rotateWebsiteSecret(String workspaceId) {
+ JsonNode body = ApiClient.unwrap(http.post("/v1/blog/settings/website-secret", workspace(workspaceId)));
+ return body.path("websiteSecret").asText(null);
+ }
+
+ /**
+ * Send a signed {@code article.test} delivery to the website target. A site that does not
+ * answer 2xx comes back with {@code ok} false and its status, rather than as an exception.
+ */
+ public BlogWebsiteTest testWebsite(String workspaceId) {
+ try {
+ return http.convert(
+ ApiClient.unwrap(http.post("/v1/blog/settings/website-test", workspace(workspaceId))),
+ BlogWebsiteTest.class);
+ } catch (FoPostException e) {
+ // The API answers 502 with the same {data: {ok, status, error}} body when the site fails.
+ if (e.status() == 502 && e.body() instanceof JsonNode body && body.has("data")) {
+ return http.convert(body.get("data"), BlogWebsiteTest.class);
+ }
+ throw e;
+ }
+ }
+
+ // ── Keywords ──
+
+ public List listKeywords(String workspaceId) {
+ return listKeywords(workspaceId, BlogParams.Filter.create());
+ }
+
+ public List listKeywords(String workspaceId, BlogParams.Filter filter) {
+ Map query = workspace(workspaceId);
+ query.putAll(filter.toQuery());
+ return http.convertList(ApiClient.unwrap(http.get("/v1/blog/keywords", query)), BlogKeyword.class);
+ }
+
+ /** Queue a planning run from the settings' topic and audience. Spends AI credits. */
+ public void planKeywords(String workspaceId) {
+ planKeywords(workspaceId, List.of());
+ }
+
+ /** Queue a planning run around these seed terms. Spends AI credits. */
+ public void planKeywords(String workspaceId, List seeds) {
+ Map body = workspace(workspaceId);
+ if (!seeds.isEmpty()) {
+ body.put("seeds", seeds);
+ }
+ http.post("/v1/blog/keywords/plan", body);
+ }
+
+ public BlogKeyword addKeyword(String workspaceId, BlogParams.Keyword params) {
+ Map body = workspace(workspaceId);
+ body.putAll(params.toMap());
+ return http.convert(ApiClient.unwrap(http.post("/v1/blog/keywords", body)), BlogKeyword.class);
+ }
+
+ public BlogKeyword updateKeyword(String workspaceId, String keywordId, BlogParams.KeywordUpdate params) {
+ return http.convert(
+ ApiClient.unwrap(http.request(
+ "PATCH", "/v1/blog/keywords/" + keywordId, params.toNode(), workspace(workspaceId))),
+ BlogKeyword.class);
+ }
+
+ public void deleteKeyword(String workspaceId, String keywordId) {
+ http.request("DELETE", "/v1/blog/keywords/" + keywordId, null, workspace(workspaceId));
+ }
+
+ // ── Articles ──
+
+ public List listArticles(String workspaceId) {
+ return listArticles(workspaceId, BlogParams.Filter.create());
+ }
+
+ public List listArticles(String workspaceId, BlogParams.Filter filter) {
+ Map query = workspace(workspaceId);
+ query.putAll(filter.toQuery());
+ return http.convertList(ApiClient.unwrap(http.get("/v1/blog/articles", query)), BlogArticle.class);
+ }
+
+ /**
+ * Ask the writer for an article. The answer is the queued row; poll {@link #getArticle} until
+ * its status leaves {@code queued} and {@code writing}. Spends AI credits.
+ */
+ public BlogArticle requestArticle(String workspaceId, BlogParams.Article params) {
+ Map body = workspace(workspaceId);
+ body.putAll(params.toMap());
+ return http.convert(ApiClient.unwrap(http.post("/v1/blog/articles", body)), BlogArticle.class);
+ }
+
+ public BlogArticleFull getArticle(String workspaceId, String articleId) {
+ return http.convert(
+ ApiClient.unwrap(http.get("/v1/blog/articles/" + articleId, workspace(workspaceId))),
+ BlogArticleFull.class);
+ }
+
+ /** Edit a draft. An article that is not editable answers 409 {@code invalid_state}. */
+ public BlogArticleFull updateArticle(String workspaceId, String articleId, BlogParams.ArticleUpdate params) {
+ return http.convert(
+ ApiClient.unwrap(http.request(
+ "PATCH", "/v1/blog/articles/" + articleId, params.toNode(), workspace(workspaceId))),
+ BlogArticleFull.class);
+ }
+
+ /** Write the article again from its instructions. Spends AI credits. */
+ public BlogArticle regenerateArticle(String workspaceId, String articleId) {
+ return action(workspaceId, articleId, "regenerate");
+ }
+
+ /**
+ * Publish a draft to the configured target. Needs {@code publish}; answers 409
+ * {@code no_target} when the settings name none.
+ */
+ public BlogArticle publishArticle(String workspaceId, String articleId) {
+ return action(workspaceId, articleId, "publish");
+ }
+
+ /** Take a draft out of review without publishing it. */
+ public BlogArticle rejectArticle(String workspaceId, String articleId) {
+ return action(workspaceId, articleId, "reject");
+ }
+
+ public void deleteArticle(String workspaceId, String articleId) {
+ http.request("DELETE", "/v1/blog/articles/" + articleId, null, workspace(workspaceId));
+ }
+
+ private BlogArticle action(String workspaceId, String articleId, String action) {
+ return http.convert(
+ ApiClient.unwrap(http.post("/v1/blog/articles/" + articleId + "/" + action, null, workspace(workspaceId))),
+ BlogArticle.class);
+ }
+
+ private static Map workspace(String workspaceId) {
+ Map map = new LinkedHashMap<>();
+ map.put("workspaceId", workspaceId);
+ return map;
+ }
+
+ private static ObjectNode withWorkspace(String workspaceId, ObjectNode fields) {
+ ObjectNode body = Json.MAPPER.createObjectNode();
+ body.put("workspaceId", workspaceId);
+ body.setAll(fields);
+ return body;
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/resource/WebhooksResource.java b/src/main/java/com/fopost/sdk/resource/WebhooksResource.java
index 062ceae..eb94e78 100644
--- a/src/main/java/com/fopost/sdk/resource/WebhooksResource.java
+++ b/src/main/java/com/fopost/sdk/resource/WebhooksResource.java
@@ -1,5 +1,7 @@
package com.fopost.sdk.resource;
+import com.fopost.sdk.WebhookEnvelope;
+import com.fopost.sdk.WebhookVerifier;
import com.fopost.sdk.internal.ApiClient;
import com.fopost.sdk.model.Webhook;
import com.fopost.sdk.param.UpdateWebhookParams;
@@ -46,4 +48,12 @@ public void delete(String webhookId) {
public void test(String webhookId) {
http.post("/v1/webhooks/" + webhookId + "/test", null);
}
+
+ /**
+ * Check a delivery's signature and parse it. {@code rawBody} is the request body exactly as
+ * received. See {@link WebhookVerifier} for the rule and the failure codes.
+ */
+ public WebhookEnvelope verify(byte[] rawBody, Map headers, String secret) {
+ return WebhookVerifier.verify(rawBody, headers, secret);
+ }
}
diff --git a/src/test/java/com/fopost/sdk/BlogTest.java b/src/test/java/com/fopost/sdk/BlogTest.java
new file mode 100644
index 0000000..30e2b44
--- /dev/null
+++ b/src/test/java/com/fopost/sdk/BlogTest.java
@@ -0,0 +1,205 @@
+package com.fopost.sdk;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.BlogArticle;
+import com.fopost.sdk.model.BlogArticleFull;
+import com.fopost.sdk.model.BlogArticleStatus;
+import com.fopost.sdk.model.BlogKeyword;
+import com.fopost.sdk.model.BlogKeywordStatus;
+import com.fopost.sdk.model.BlogSettings;
+import com.fopost.sdk.model.BlogSettingsUpdate;
+import com.fopost.sdk.model.BlogTargetKind;
+import com.fopost.sdk.model.BlogWebsiteTest;
+import com.fopost.sdk.param.BlogParams;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class BlogTest {
+
+ private static final String SETTINGS =
+ """
+ {"siteUrl":"https://yourbrand.com","topic":"Scheduling","audience":"Marketers",
+ "language":"en","locationCode":2840,"cadencePerWeek":2,"reviewHoldDays":3,
+ "autoPublish":false,"imageTier":"standard","inBodyImages":2,"minWords":900,
+ "maxWords":1500,"brandVoiceId":null,"targetKind":"website","targetAccountId":null,
+ "targetPlatform":null,"websiteUrl":"https://yourbrand.com/hooks/fopost",
+ "hasWebsiteSecret":true,"keywordDataAvailable":true,
+ "updatedAt":"2026-10-11T09:00:00.000Z"}""";
+
+ private static final String KEYWORD =
+ """
+ {"id":"kw_1","keyword":"how to schedule posts","searchVolume":1200,"difficulty":34,
+ "cpc":1.25,"intent":"informational","source":"provider","status":"planned",
+ "plannedFor":"2026-10-14","createdAt":"2026-10-11T09:00:00.000Z"}""";
+
+ private static final String ARTICLE =
+ """
+ {"id":"art_1","title":"How to schedule posts","slug":"how-to-schedule-posts",
+ "keyword":"how to schedule posts","excerpt":null,"metaTitle":null,"metaDescription":null,
+ "featuredImage":{"mediaId":"m_1","url":"https://cdn.example/f.png","alt":"Calendar"},
+ "tags":["scheduling"],"wordCount":0,"status":"queued","origin":"user",
+ "reviewUntil":null,"publishAt":null,"publishedAt":null,"publishedUrl":null,"error":null,
+ "createdAt":"2026-10-11T09:00:00.000Z","updatedAt":"2026-10-11T09:00:00.000Z"}""";
+
+ @Test
+ void settingsTravelOnTheQueryForAReadAndInTheBodyForAnUpdate() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, "{\"data\":" + SETTINGS + "}")
+ .enqueue(200, "{\"data\":" + SETTINGS + ",\"websiteSecret\":\"whsec_abc\"}")
+ .enqueue(200, "{\"data\":{\"websiteSecret\":\"whsec_def\"}}")
+ .enqueue(502, "{\"data\":{\"ok\":false,\"status\":500,\"error\":\"HTTP 500\"}}");
+ FoPost client = TestSupport.client(transport);
+
+ BlogSettings settings = client.blog().getSettings("w1");
+ assertEquals("https://api.fopost.test/v1/blog/settings?workspaceId=w1", transport.last().url());
+ assertEquals(BlogTargetKind.WEBSITE, settings.targetKind());
+ assertEquals(2, settings.cadencePerWeek());
+ assertTrue(settings.hasWebsiteSecret());
+ assertEquals(Instant.parse("2026-10-11T09:00:00Z"), settings.updatedAt());
+
+ BlogSettingsUpdate updated = client.blog().updateSettings(
+ "w1",
+ BlogParams.Settings.create()
+ .websiteUrl("https://yourbrand.com/hooks/fopost")
+ .targetKind(BlogTargetKind.WEBSITE)
+ .brandVoiceId(null)
+ .cadencePerWeek(2));
+ assertEquals("PATCH", transport.last().method());
+ assertEquals("https://api.fopost.test/v1/blog/settings", transport.last().url());
+ assertEquals(
+ "{\"workspaceId\":\"w1\",\"websiteUrl\":\"https://yourbrand.com/hooks/fopost\","
+ + "\"targetKind\":\"website\",\"brandVoiceId\":null,\"cadencePerWeek\":2}",
+ transport.lastBody());
+ assertEquals("whsec_abc", updated.websiteSecret());
+ assertEquals("en", updated.settings().language());
+
+ assertEquals("whsec_def", client.blog().rotateWebsiteSecret("w1"));
+ assertEquals("https://api.fopost.test/v1/blog/settings/website-secret", transport.last().url());
+ assertEquals("{\"workspaceId\":\"w1\"}", transport.lastBody());
+
+ BlogWebsiteTest test = client.blog().testWebsite("w1");
+ assertEquals("https://api.fopost.test/v1/blog/settings/website-test", transport.last().url());
+ assertFalse(test.ok());
+ assertEquals(500, test.status());
+ assertEquals("HTTP 500", test.error());
+ }
+
+ @Test
+ void keywordsFilterOnTheQueryAndClearAPlannedDateWithNull() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, "{\"data\":[" + KEYWORD + "]}")
+ .enqueue(202, "{\"data\":{\"status\":\"queued\"}}")
+ .enqueue(201, "{\"data\":" + KEYWORD + "}")
+ .enqueue(200, "{\"data\":" + KEYWORD + "}")
+ .enqueue(204, "");
+ FoPost client = TestSupport.client(transport);
+
+ List keywords = client.blog().listKeywords(
+ "w1", BlogParams.Filter.create().status(BlogKeywordStatus.SUGGESTED, BlogKeywordStatus.PLANNED).limit(20));
+ assertEquals(
+ "https://api.fopost.test/v1/blog/keywords?workspaceId=w1&status=suggested%2Cplanned&limit=20",
+ transport.last().url());
+ assertEquals(1200, keywords.get(0).searchVolume());
+ assertEquals(LocalDate.of(2026, 10, 14), keywords.get(0).plannedFor());
+ assertEquals(1.25, keywords.get(0).cpc());
+
+ client.blog().planKeywords("w1", List.of("scheduling", "analytics"));
+ assertEquals("https://api.fopost.test/v1/blog/keywords/plan", transport.last().url());
+ assertEquals("{\"workspaceId\":\"w1\",\"seeds\":[\"scheduling\",\"analytics\"]}", transport.lastBody());
+
+ BlogKeyword added = client.blog().addKeyword(
+ "w1", BlogParams.Keyword.of("how to schedule posts").plannedFor(LocalDate.of(2026, 10, 14)));
+ assertEquals("https://api.fopost.test/v1/blog/keywords", transport.last().url());
+ assertEquals(
+ "{\"workspaceId\":\"w1\",\"keyword\":\"how to schedule posts\",\"plannedFor\":\"2026-10-14\"}",
+ transport.lastBody());
+ assertEquals("kw_1", added.id());
+
+ client.blog().updateKeyword(
+ "w1", "kw_1", BlogParams.KeywordUpdate.create().status(BlogKeywordStatus.DISMISSED).plannedFor(null));
+ assertEquals("PATCH", transport.last().method());
+ assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspaceId=w1", transport.last().url());
+ assertEquals("{\"status\":\"dismissed\",\"plannedFor\":null}", transport.lastBody());
+
+ client.blog().deleteKeyword("w1", "kw_1");
+ assertEquals("DELETE", transport.last().method());
+ assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspaceId=w1", transport.last().url());
+ assertNull(transport.last().body());
+ }
+
+ @Test
+ void articlesAreRequestedInTheBodyAndActedOnByPath() {
+ String full = ARTICLE.substring(0, ARTICLE.length() - 1)
+ + ",\"instructions\":\"Keep it short\",\"outline\":[\"Why\",\"How\"],"
+ + "\"brief\":{\"sources\":2},\"bodyMarkdown\":\"# Hi\",\"bodyHtml\":\"Hi
\","
+ + "\"images\":[{\"mediaId\":\"m_2\",\"url\":\"https://cdn.example/i.png\",\"alt\":\"Step\",\"position\":1}]}";
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, "{\"data\":[" + ARTICLE + "]}")
+ .enqueue(202, "{\"data\":" + ARTICLE + "}")
+ .enqueue(200, "{\"data\":" + full + "}")
+ .enqueue(200, "{\"data\":" + full + "}")
+ .enqueue(202, "{\"data\":" + ARTICLE + "}")
+ .enqueue(202, "{\"data\":" + ARTICLE + "}")
+ .enqueue(200, "{\"data\":" + ARTICLE + "}")
+ .enqueue(204, "")
+ .enqueue(402, "{\"error\":\"insufficient_credits\",\"message\":\"Balance\",\"upgrade_url\":\"https://fopost.com/billing\"}")
+ .enqueue(409, "{\"error\":\"no_target\",\"message\":\"Pick a target first\"}");
+ FoPost client = TestSupport.client(transport);
+
+ List articles = client.blog().listArticles(
+ "w1", BlogParams.Filter.create().status(BlogArticleStatus.DRAFT));
+ assertEquals("https://api.fopost.test/v1/blog/articles?workspaceId=w1&status=draft", transport.last().url());
+ assertEquals("Calendar", articles.get(0).featuredImage().alt());
+ assertEquals(List.of("scheduling"), articles.get(0).tags());
+
+ BlogArticle requested = client.blog().requestArticle(
+ "w1", BlogParams.Article.create().keyword("how to schedule posts").instructions("Keep it short"));
+ assertEquals("https://api.fopost.test/v1/blog/articles", transport.last().url());
+ assertEquals(
+ "{\"workspaceId\":\"w1\",\"keyword\":\"how to schedule posts\",\"instructions\":\"Keep it short\"}",
+ transport.lastBody());
+ assertEquals(BlogArticleStatus.QUEUED, requested.status());
+
+ BlogArticleFull fetched = client.blog().getArticle("w1", "art_1");
+ assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url());
+ assertEquals("# Hi", fetched.bodyMarkdown());
+ assertEquals(List.of("Why", "How"), fetched.outline());
+ assertEquals(2, fetched.brief().path("sources").asInt());
+ assertEquals(1, fetched.images().get(0).position());
+
+ client.blog().updateArticle(
+ "w1", "art_1", BlogParams.ArticleUpdate.create().title("New title").tags(List.of("a", "b")).publishAt(null));
+ assertEquals("PATCH", transport.last().method());
+ assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url());
+ assertEquals("{\"title\":\"New title\",\"tags\":[\"a\",\"b\"],\"publishAt\":null}", transport.lastBody());
+
+ client.blog().regenerateArticle("w1", "art_1");
+ assertEquals("https://api.fopost.test/v1/blog/articles/art_1/regenerate?workspaceId=w1", transport.last().url());
+ client.blog().publishArticle("w1", "art_1");
+ assertEquals("https://api.fopost.test/v1/blog/articles/art_1/publish?workspaceId=w1", transport.last().url());
+ assertEquals("POST", transport.last().method());
+ assertNull(transport.last().body());
+ client.blog().rejectArticle("w1", "art_1");
+ assertEquals("https://api.fopost.test/v1/blog/articles/art_1/reject?workspaceId=w1", transport.last().url());
+ client.blog().deleteArticle("w1", "art_1");
+ assertEquals("DELETE", transport.last().method());
+ assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url());
+
+ PaymentRequiredException noCredits = assertThrows(
+ PaymentRequiredException.class,
+ () -> client.blog().requestArticle("w1", BlogParams.Article.create().title("Again")));
+ assertEquals("insufficient_credits", noCredits.code());
+ assertEquals("https://fopost.com/billing", noCredits.upgradeUrl());
+
+ FoPostException noTarget = assertThrows(FoPostException.class, () -> client.blog().publishArticle("w1", "art_1"));
+ assertEquals(409, noTarget.status());
+ assertEquals("no_target", noTarget.code());
+ }
+}
diff --git a/src/test/java/com/fopost/sdk/WebhookVerifierTest.java b/src/test/java/com/fopost/sdk/WebhookVerifierTest.java
new file mode 100644
index 0000000..1e5b41e
--- /dev/null
+++ b/src/test/java/com/fopost/sdk/WebhookVerifierTest.java
@@ -0,0 +1,88 @@
+package com.fopost.sdk;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.fopost.sdk.model.BlogArticlePublished;
+import com.fopost.sdk.model.WebhookEvents;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class WebhookVerifierTest {
+
+ private static final String SECRET = "whsec_test";
+ private static final long NOW = 1_760_000_000L;
+ private static final Clock CLOCK = Clock.fixed(Instant.ofEpochSecond(NOW), ZoneOffset.UTC);
+
+ private static final String BODY =
+ """
+ {"event":"article.published","timestamp":"2026-10-11T09:00:00.000Z",
+ "data":{"id":"art_1","title":"How to schedule posts","slug":"how-to-schedule-posts",
+ "excerpt":null,"metaTitle":null,"metaDescription":null,
+ "bodyMarkdown":"# Hi","bodyHtml":"Hi
",
+ "featuredImage":{"mediaId":"m_1","url":"https://cdn.example/f.png","alt":"Calendar"},
+ "images":[],"tags":["scheduling"],"keyword":"how to schedule posts",
+ "wordCount":2,"publishedAt":"2026-10-11T09:00:00.000Z",
+ "publishedUrl":"https://yourbrand.com/blog/how-to-schedule-posts"}}""";
+
+ private static byte[] bytes(String body) {
+ return body.getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static Map timestamped(String body, long timestamp) {
+ return Map.of(
+ "x-fopost-timestamp", Long.toString(timestamp),
+ "x-fopost-signature-256", WebhookVerifier.sign(bytes(body), SECRET, timestamp),
+ "x-fopost-signature", WebhookVerifier.sign(bytes(body), SECRET));
+ }
+
+ @Test
+ void aFreshTimestampedSignaturePassesAndParsesTheArticle() {
+ WebhookEnvelope envelope = WebhookVerifier.verify(bytes(BODY), timestamped(BODY, NOW - 10), SECRET, CLOCK);
+
+ assertEquals(WebhookEvents.ARTICLE_PUBLISHED, envelope.event());
+ assertEquals("2026-10-11T09:00:00.000Z", envelope.timestamp());
+ BlogArticlePublished article = envelope.data(BlogArticlePublished.class);
+ assertEquals("how-to-schedule-posts", article.slug());
+ assertEquals("Calendar", article.featuredImage().alt());
+ assertEquals("https://yourbrand.com/blog/how-to-schedule-posts", article.publishedUrl());
+ assertEquals(Instant.parse("2026-10-11T09:00:00Z"), article.publishedAt());
+ }
+
+ @Test
+ void aTamperedBodyFails() {
+ String tampered = BODY.replace("How to schedule posts", "Something else");
+
+ FoPostException error = assertThrows(
+ FoPostException.class,
+ () -> WebhookVerifier.verify(bytes(tampered), timestamped(BODY, NOW), SECRET, CLOCK));
+ assertEquals("invalid_signature", error.code());
+ }
+
+ @Test
+ void aStaleTimestampFailsEvenWhenTheSignatureMatches() {
+ FoPostException error = assertThrows(
+ FoPostException.class,
+ () -> WebhookVerifier.verify(bytes(BODY), timestamped(BODY, NOW - 301), SECRET, CLOCK));
+ assertEquals("stale_timestamp", error.code());
+
+ WebhookVerifier.verify(bytes(BODY), timestamped(BODY, NOW - 300), SECRET, CLOCK);
+ }
+
+ @Test
+ void theBodyOnlySignatureIsAcceptedWhenNoTimestampIsSent() {
+ Map legacy = Map.of("X-FoPost-Signature", WebhookVerifier.sign(bytes(BODY), SECRET));
+
+ WebhookEnvelope envelope = WebhookVerifier.verify(BODY, legacy, SECRET);
+ assertEquals(WebhookEvents.ARTICLE_PUBLISHED, envelope.event());
+
+ FoPostException error = assertThrows(
+ FoPostException.class,
+ () -> WebhookVerifier.verify(bytes(BODY), Map.of("X-FoPost-Signature", "sha256=00"), SECRET));
+ assertEquals("invalid_signature", error.code());
+ }
+}