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

### Added

- `broadcasts()`: one message into every conversation the workspace already has with a
segment of its contacts. `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`
and `recipients`. Reading needs the `inbox` scope; `send` and `cancel` also need `publish`.
- `sequences()`: a series of messages on a delay. `list`, `get`, `create`, `update`,
`delete`, `enroll`, `unenroll` and `enrollments`. `enroll` and `unenroll` need `publish`.
- Both honour each network's messaging window server-side. Messenger and Instagram take a
business-initiated message only within 24 hours of the contact's last one, so recipients
outside it come back `skipped` with `skip_reason` `window_closed` and nothing is
attempted — the number sent is often lower than the audience.

- `contacts()`: the people behind the inbox. `list`, `get`, `create`, `update`, `delete`,
`conversations` (the threads one person appears in), `importCsv`, and
`listFields`/`createField`/`updateField`/`updateFieldOptions`/`deleteField` for the
custom columns a workspace keeps. All need the `inbox` scope.
- `contacts().conversationAnalytics` reads `/v1/analytics/inbox/conversations`: volume and
median reply time per thread. Needs the `analytics` scope.
- Meta messaging settings on `accounts()`: `getIceBreakers`, `setIceBreakers` and
`deleteIceBreakers` (Facebook Pages and Instagram), plus `getPersistentMenu`,
`setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting` and
Expand Down
19 changes: 17 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ src/main/java/com/fopost/sdk/
resource/ PostsResource, AccountsResource (+ .communities()), WorkspacesResource,
LabelsResource, WebhooksResource, AnalyticsResource,
AutomationsResource, MediaResource, AiResource, CommunitiesResource,
InboxResource, AdsResource, ValidateResource
InboxResource, AdsResource, ValidateResource,
BroadcastsResource, SequencesResource
```

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

- `contacts` (scope `inbox`) covers `/v1/contacts/*`: the CRUD, `import`,
`{id}/conversations` and the `/v1/contacts/fields` family. Its list envelope is
`{data, pagination}`, so it decodes into `ContactPage` rather than `Page<T>`.
`createField` puts the workspace on the query string because the handler reads it there.
`ContactParams.Update` serializes its `fields` through an `ObjectNode`: the mapper is
configured `NON_NULL`, so a plain map would drop the null that clears a field.
`conversationAnalytics` reaches `/v1/analytics/inbox/conversations` and needs `analytics`.
- `broadcasts` and `sequences` (scope `inbox`) cover `/v1/broadcasts/*` and `/v1/sequences/*`.
`send`, `cancel`, `enroll` and `unenroll` also need `publish`, because they reach a platform.
Both list envelopes are `{data, pagination}` like contacts, so both decode into their own
page records. A recipient's `skipReason` is the messaging window's record: `window_closed`
means the network's 24-hour window had shut and nothing was attempted, so a sent count lower
than the audience is correct rather than a failure. Say that in the javadoc of anything new
that sends.
- `inbox` (scope `inbox`) covers `/v1/inbox/*` except `/v1/inbox/chat/*` (browser-encrypted X
Chat) and `/v1/inbox/{id}/attachments/{index}` (a binary stream; this SDK has no download
pattern). Its lists carry `meta: { page, perPage, total }`, mapped by `model/InboxPage` +
Expand Down
78 changes: 77 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| `media()` | `list`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` |
| `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` |
| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `passThreadControl`, `takeThreadControl`, `handover`, `listApprovals`, `approveReply`, `rejectReply` |
| `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` |
| `contacts()` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `importCsv`, `listFields`, `createField`, `updateField`, `updateFieldOptions`, `deleteField`, `conversationAnalytics` |
| `broadcasts()` | `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`, `recipients` |
| `sequences()` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` |
| `knowledge()` | `list`, `create`, `createText`, `createUrl`, `createFile`, `update`, `delete`, `sync`, `search` |
| `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage` |
| `validate()` | `post`, `length`, `media` |
Expand All @@ -123,6 +125,41 @@ the decoded body:
JsonNode body = client.request("GET", "/v1/analytics/overview", null, Map.of("days", 30));
```

## Broadcasts and sequences

A broadcast is one message into every conversation you already have with a segment of your contacts; a sequence is a series of them on a delay. Neither opens a cold DM.

Nothing is sent into a closed messaging window: Messenger and Instagram take a business-initiated message only within 24 hours of the contact's last one, so recipients outside it come back skipped with `window_closed` rather than attempted. Telegram, Slack, Bluesky and Reddit have no window. The number sent is therefore often lower than the audience, and that is correct rather than a failure.

Reading needs the `inbox` scope; `send`, `cancel`, `enroll` and `unenroll` also need `publish`.

```java
Broadcast broadcast = client.broadcasts().create(
BroadcastParams.Create.of(workspaceId, accountId, "September check-in",
"New colours just landed. Want a look?")
.audience(AudienceFilter.all().platforms("instagram")));

// recipients() on the result is how many contacts matched, not how many will be messaged.
BroadcastSent sent = client.broadcasts().send(broadcast.id());

// Who was skipped, and why.
for (BroadcastRecipient r : client.broadcasts()
.recipients(broadcast.id(), BroadcastParams.Recipients.create().status("skipped"))) {
System.out.println(r.displayName() + ": " + r.skipReason());
}

Sequence sequence = client.sequences().create(
BroadcastParams.CreateSequence.of(workspaceId, accountId, "Welcome", List.of(
SequenceStep.of(0, "Thanks for the follow — anything I can help with?"),
SequenceStep.of(48, "Here is what people usually ask us first."))));

// By id, or by the same audience filter a broadcast takes.
client.sequences().enroll(sequence.id(), BroadcastParams.Enroll.contacts(List.of(contactId)));

// Nothing further fires for them.
client.sequences().unenroll(sequence.id(), List.of(contactId));
```

## Media

Upload once, then attach the returned file to a content block:
Expand Down Expand Up @@ -206,6 +243,45 @@ The actions that act on the platform as the account also need the `publish` scop
`InboxReplyParams` carrying `mediaIds` or `quickReplies`, and deleting our own reply. Each works only
where the item's matching `can*` flag is true.

## Contacts

The people behind the inbox: one person however many handles they write from. An inbound item files its author, a reply files whoever you answered, and both fold into whatever is already on file. Needs the `inbox` scope.

```java
ContactPage page = client.contacts().list(
ContactParams.Filter.create().workspace(workspaceId).search("ada"));
for (Contact contact : page) {
System.out.println(contact.displayName() + " — " + contact.channels().size() + " handles");
}

// Folds into whoever already holds the first channel, so this cannot duplicate someone.
Contact contact = client.contacts().create(
ContactParams.Create.of(workspaceId, List.of(ContactChannel.of("x", "ada_writes")))
.displayName("Ada Okafor")
.fields(Map.of("plan_tier", "Pro")));

client.contacts().update(contact.id(), ContactParams.Update.create().clearField("region"));
client.contacts().delete(contact.id()); // the messages stay in the inbox

// The threads this person appears in, newest first.
for (ContactConversation thread : client.contacts().conversations(contact.id())) {
System.out.println(thread.platform() + " " + thread.messages() + " messages");
}

// platform and handle are required columns; any other column is a custom field key.
ContactImportResult result = client.contacts().importCsv(workspaceId, "platform,handle\nx,ada_writes");
System.out.println(result.created() + " created, " + result.merged() + " merged");

// The columns your workspace keeps.
ContactField field = client.contacts()
.createField(workspaceId, "plan_tier", "Plan Tier", "select", List.of("Free", "Pro"));
client.contacts().deleteField(field.id()); // removes every answer to it

// Volume and median reply time per thread. Needs the `analytics` scope.
ConversationAnalytics report = client.contacts()
.conversationAnalytics(ContactParams.Conversations.create().days(30).sort("slowest"));
```

## Ads

Boosts, standalone ads, audiences and lead forms on the connected ad accounts.
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/com/fopost/sdk/FoPost.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
import com.fopost.sdk.resource.AiResource;
import com.fopost.sdk.resource.AnalyticsResource;
import com.fopost.sdk.resource.AutomationsResource;
import com.fopost.sdk.resource.BroadcastsResource;
import com.fopost.sdk.resource.ContactsResource;
import com.fopost.sdk.resource.InboxResource;
import com.fopost.sdk.resource.KnowledgeResource;
import com.fopost.sdk.resource.LabelsResource;
import com.fopost.sdk.resource.MediaResource;
import com.fopost.sdk.resource.PostsResource;
import com.fopost.sdk.resource.SequencesResource;
import com.fopost.sdk.resource.ValidateResource;
import com.fopost.sdk.resource.WebhooksResource;
import com.fopost.sdk.resource.WorkspacesResource;
Expand Down Expand Up @@ -68,6 +71,9 @@ public final class FoPost {
private final AutomationsResource automations;
private final MediaResource media;
private final AiResource ai;
private final ContactsResource contacts;
private final BroadcastsResource broadcasts;
private final SequencesResource sequences;
private final InboxResource inbox;
private final AdsResource ads;
private final ValidateResource validate;
Expand All @@ -86,6 +92,9 @@ private FoPost(ApiClient http) {
this.media = new MediaResource(http);
this.ai = new AiResource(http);
this.inbox = new InboxResource(http);
this.contacts = new ContactsResource(http);
this.broadcasts = new BroadcastsResource(http);
this.sequences = new SequencesResource(http);
this.ads = new AdsResource(http);
this.validate = new ValidateResource(http);
}
Expand Down Expand Up @@ -152,6 +161,24 @@ public InboxResource inbox() {
return inbox;
}

/** The people behind the inbox, and the fields kept about them. */
public ContactsResource contacts() {
return contacts;
}

/**
* One message into every conversation the workspace already has with a segment of its
* contacts.
*/
public BroadcastsResource broadcasts() {
return broadcasts;
}

/** A series of messages on a delay, walked per enrolled contact. */
public SequencesResource sequences() {
return sequences;
}

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

import com.fasterxml.jackson.annotation.JsonInclude;

/**
* One custom-field clause in an audience filter.
*
* <p>{@code op} is {@code is}, {@code is_not}, {@code contains}, {@code is_set} or
* {@code is_not_set}; null means {@code is}.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record AudienceField(String key, String op, String value) {

public static AudienceField is(String key, String value) {
return new AudienceField(key, "is", value);
}

public static AudienceField isNot(String key, String value) {
return new AudienceField(key, "is_not", value);
}

public static AudienceField contains(String key, String value) {
return new AudienceField(key, "contains", value);
}

public static AudienceField isSet(String key) {
return new AudienceField(key, "is_set", null);
}

public static AudienceField isNotSet(String key) {
return new AudienceField(key, "is_not_set", null);
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/fopost/sdk/model/AudienceFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.fopost.sdk.model;

import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;

/**
* Who a broadcast or an enrollment resolves to, expressed over contacts.
*
* <p>Every clause narrows: a contact has to match all of them. A null clause is not sent.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record AudienceFilter(
List<String> platforms, List<String> labelIds, String source, List<AudienceField> fields) {

/** Everyone in the workspace. */
public static AudienceFilter all() {
return new AudienceFilter(null, null, null, null);
}

/** Contacts with a handle on at least one of these networks. */
public AudienceFilter platforms(String... platforms) {
return new AudienceFilter(List.of(platforms), labelIds, source, fields);
}

public AudienceFilter labelIds(String... ids) {
return new AudienceFilter(platforms, List.of(ids), source, fields);
}

/** {@code inbox}, {@code radar} or {@code import}. */
public AudienceFilter source(String source) {
return new AudienceFilter(platforms, labelIds, source, fields);
}

public AudienceFilter fields(List<AudienceField> fields) {
return new AudienceFilter(platforms, labelIds, source, fields);
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/fopost/sdk/model/Broadcast.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.fopost.sdk.model;

import java.time.Instant;

/**
* One message, sent into conversations the workspace already has.
*
* <p>{@code name} is internal only and is never sent to anyone. {@code status} is
* {@code draft}, {@code scheduled}, {@code sending}, {@code sent} or {@code cancelled}.
* {@code workspaceId} is set only on a listing that spans workspaces.
*/
public record Broadcast(
String id,
String name,
String text,
String accountId,
AudienceFilter audience,
String status,
Instant scheduledAt,
Instant sentAt,
Instant createdAt,
BroadcastCounts counts,
String workspaceId) {}
8 changes: 8 additions & 0 deletions src/main/java/com/fopost/sdk/model/BroadcastCounts.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.fopost.sdk.model;

/**
* What became of a broadcast's recipients, by status.
*
* <p>{@code skipped} is usually the messaging window doing its job.
*/
public record BroadcastCounts(int total, int sent, int skipped, int failed, int pending) {}
26 changes: 26 additions & 0 deletions src/main/java/com/fopost/sdk/model/BroadcastPage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.fopost.sdk.model;

import java.util.Iterator;
import java.util.List;

/** One page of broadcasts: its rows plus the pagination block. Iterates over its rows. */
public record BroadcastPage(List<Broadcast> data, BroadcastPageMeta pagination)
implements Iterable<Broadcast> {

public BroadcastPage {
data = data == null ? List.of() : List.copyOf(data);
}

@Override
public Iterator<Broadcast> iterator() {
return data.iterator();
}

public int size() {
return data.size();
}

public boolean isEmpty() {
return data.isEmpty();
}
}
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/BroadcastPageMeta.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** The pagination block a broadcast, recipient, sequence or enrollment listing carries. */
public record BroadcastPageMeta(int page, int perPage, long total) {}
19 changes: 19 additions & 0 deletions src/main/java/com/fopost/sdk/model/BroadcastRecipient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.fopost.sdk.model;

import java.time.Instant;

/**
* One contact on one broadcast, and what became of their message.
*
* <p>{@code status} is {@code pending}, {@code sent}, {@code skipped} or {@code failed}.
* {@code skipReason} is set when the status is {@code skipped}: {@code window_closed},
* {@code no_conversation} or {@code unsupported_platform}. {@code window_closed} means the
* network's messaging window had shut, so nothing was attempted.
*/
public record BroadcastRecipient(
String contactId,
String displayName,
String status,
String skipReason,
Instant sentAt,
String error) {}
9 changes: 9 additions & 0 deletions src/main/java/com/fopost/sdk/model/BroadcastSent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.fopost.sdk.model;

/**
* What a send started.
*
* <p>{@code recipients} is how many contacts matched, not how many will be messaged — the
* messaging window decides that.
*/
public record BroadcastSent(String id, String status, int recipients) {}
Loading
Loading