# Java Social Media API SDK

The official Java SDK. Java 11+, built on `java.net.http.HttpClient`, single
dependency (Jackson), Jackson `JsonNode` responses with a fluent params builder.

## Install

Maven:

```xml
<dependency>
  <groupId>com.omnisocials</groupId>
  <artifactId>omnisocials-java</artifactId>
  <version>0.10.0</version>
</dependency>
```

Gradle:

```groovy
implementation "com.omnisocials:omnisocials-java:0.10.0"
```

## Authentication

```java
OmniSocials client = OmniSocials.builder()
    .apiKey("omsk_live_...")
    .build();
```

Or `OmniSocials.fromEnv()` (reads `OMNISOCIALS_API_KEY`). A missing key throws
`AuthenticationException`.

## Create a post

```java
JsonNode res = client.posts().create(Params.builder()
    .put("content", "New drop this Friday")
    .put("channels", List.of("instagram", "facebook", "linkedin"))
    .put("scheduled_at", "2026-08-01T09:00:00Z")
    .put("media_urls", List.of("https://example.com/teaser.jpg"))
    .build());
JsonNode post = res.get("data");
System.out.println(post.get("id").asText() + " " + post.get("status").asText());
```

Any `media_urls` (or `media_ids`) entry can also be a map carrying per-media
alt text (max 1500 chars), delivered to Mastodon, Bluesky, X (photos/GIFs),
Pinterest, Instagram (images), and LinkedIn (images):

```java
.put("media_urls", List.of(Params.of(
    "url", "https://example.com/teaser.jpg",
    "alt", "Red sneaker on a white background")))
```

Publish immediately:

```java
client.posts().createAndPublish(Params.builder()
    .put("content", "Going live right now")
    .put("channels", List.of("x", "bluesky"))
    .build());
```

## Upload media

```java
JsonNode upload = client.media().uploadFromUrl(Params.builder()
    .put("url", "https://example.com/launch-video.mp4")
    .put("name", "launch-video-v2")
    .put("folder", "Campaigns")
    .build());
System.out.println(upload.get("data").get("id").asText());
System.out.println(upload.get("compatibility"));
```

Local files via multipart:

```java
import java.nio.file.Path;

// From a path (filename and content type detected from the file)
JsonNode res = client.media().upload(Path.of("photos/product.jpg"), Params.of("name", "product-hero"));

// Or from bytes + filename
byte[] bytes = java.nio.file.Files.readAllBytes(Path.of("photos/product.jpg"));
client.media().upload(bytes, "product.jpg");
```

## Analytics

```java
// One post's latest per-platform metrics
JsonNode stats = client.analytics().post("post_id");
System.out.println(stats.get("data").get("platforms").path("instagram").path("metrics"));

// Batch: up to 100 posts in one call
JsonNode batch = client.analytics().posts(List.of("id1", "id2", "id3"));
// or: client.analytics().posts("id1", "id2", "id3");

// Workspace-wide overview
JsonNode overview = client.analytics().overview(Params.of("period", "30d"));
System.out.println(overview.get("data").get("total_impressions").asLong());

// Account-level stats (followers etc)
JsonNode accountStats = client.analytics().accounts(Params.of("platform", "instagram"));
```

## Social Inbox

Read and answer DMs, comments and mentions from your connected accounts. The API key
needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination
(`cursor` in, `pagination.next_cursor` out).

```java
JsonNode conversations = client.inbox().listConversations(
    Params.of("platform", "instagram", "unread", true));
String conversationId = conversations.get("data").get(0).get("conversation_id").asText();

JsonNode messages = client.inbox().getMessages(conversationId);
client.inbox().reply(conversationId, Params.of("text", "Thanks for reaching out!"));
client.inbox().markRead(conversationId);

// Comment moderation: hide (hide(messageId, false) unhides) or delete
String messageId = messages.get("data").get(0).get("id").asText();
client.inbox().hide(messageId);
client.inbox().deleteMessage(messageId);
```

`inbox().next()` hands out the next conversation that still needs an answer, with the
thread and the post it belongs to. On a comment thread, pass `message_id` so the reply
goes under the comment you answer and not under the newest comment on the post.

```java
JsonNode next = client.inbox().next().get("data");
while (next != null && !next.isNull()) {
  JsonNode message = next.get("message");

  if (!next.get("reply_window").get("open").asBoolean()) {
    // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app
    client.inbox().markRead(message.get("conversation_id").asText());
    next = client.inbox().next().get("data");
    continue;
  }

  JsonNode reply = client.inbox().reply(
      message.get("conversation_id").asText(),
      Params.of(
          "text", "Thanks! DM sent.",
          "message_id", message.get("id").asText(),
          "include_next", true));
  next = reply.get("next");
}
```

See the [Social Inbox guide](/inbox) for the platforms, the reply rules and the error
codes.

## Pinterest product tags

Tag products on a Pin so people can shop the items in the image. List the product Pins
of the connected Pinterest account, then pass up to 24 of their Pin ids as
`product_tags` in the `pinterest` map of the post. Only product Pins of your own account
can be tagged; products of other merchants cannot.

```java
JsonNode result = client.pinterest().listProducts();

if (result.has("error")) {
  System.err.println(result.get("error").get("code").asText());
} else {
  List<String> productTags = new ArrayList<>();
  for (JsonNode product : result.get("products")) {
    if (productTags.size() < 3) {
      productTags.add(product.get("pin_id").asText());
    }
  }

  client.posts().create(Params.builder()
      .put("content", "Our summer picks")
      .put("channels", List.of("pinterest"))
      .put("media_urls", List.of("https://example.com/summer-look.jpg"))
      .put("scheduled_at", "2026-08-01T09:00:00Z")
      .put("pinterest", Params.of("board_id", "1234567890", "product_tags", productTags))
      .build());
}
```

The list answers with HTTP 200 in both cases: `products` on success, or an `error`
object (`pinterest_not_connected`, `pinterest_catalog_access_required`,
`platform_error`) when it could not be read. It reads the Pinterest catalog when the
connection has catalog access (given one time in the OmniSocials composer: Pinterest
options, Add products, Connect catalog), else the account's own Pins. The tags are added
right after the Pin is published. A product that Pinterest refuses never fails the post:
the post then carries `pinterest.product_tags_result` with the tagged and the skipped
Pin ids. `client.pinterest().validateProduct(id)` checks one Pin id or Pin link before
you post. On `posts().update()` the `pinterest` map replaces the stored one, so leave
`product_tags` out to remove the tags.

## Verify webhooks

Spring Boot:

```java
import com.fasterxml.jackson.databind.JsonNode;
import com.omnisocials.Webhooks;
import com.omnisocials.errors.WebhookVerificationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OmniSocialsWebhookController {

  private final String secret = System.getenv("OMNISOCIALS_WEBHOOK_SECRET");

  @PostMapping("/omnisocials/webhook")
  public ResponseEntity<Void> handle(
      @RequestBody String rawBody, // Spring gives you the raw body as a String
      @RequestHeader("X-OmniSocials-Signature") String signature) {
    JsonNode event;
    try {
      event = Webhooks.verifySignature(rawBody, signature, secret, 300);
    } catch (WebhookVerificationException e) {
      return ResponseEntity.badRequest().build();
    }

    switch (event.get("type").asText()) {
      case "post.published":
        System.out.println("Published: " + event.get("data").get("post_id").asText());
        break;
      case "post.failed":
        System.err.println("Failed: " + event.get("data").get("post_id").asText());
        break;
      case "post.rejected":
        System.err.println("Rejected: " + event.get("data").get("post_id").asText()
            + " " + event.get("data").get("approval").get("reason").asText());
        break;
      default:
        break;
    }
    return ResponseEntity.ok().build();
  }
}
```

Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`.
The two approval events carry `data.approval` with `status`, `decided_by` (the approver's
user id) and `reason` (null on `post.approved`). To read the full review of a post (steps,
approvers, the rejection reason and the comment thread), call `client.posts().getApproval(id)`.

## Errors

All exceptions extend `OmniSocialsException` (unchecked) and live in
`com.omnisocials.errors`. `ApiException` exposes `getStatus()`, `getCode()`,
`getMessage()`, and `getBody()`. Types: `ValidationException` (400/422),
`AuthenticationException` (401), `PermissionDeniedException` (403),
`NotFoundException` (404), `RateLimitException` (429, `getRetryAfter()`),
`ServerException` (5xx), `ApiConnectionException`, `WebhookVerificationException`.

```java
import com.omnisocials.errors.*;

try {
  client.posts().create(Params.of("content", "Hi", "channels", List.of("instagram")));
} catch (RateLimitException e) {
  System.out.println("Rate limited, retry in " + e.getRetryAfter() + "s");
} catch (ValidationException e) {
  System.err.println("Bad request (" + e.getCode() + "): " + e.getMessage());
  System.err.println(e.getBody());
} catch (ApiConnectionException e) {
  System.err.println("Network problem: " + e.getMessage());
} catch (ApiException e) {
  System.err.println("API error " + e.getStatus() + " (" + e.getCode() + "): " + e.getMessage());
}
```

## Configuration

```java
import java.time.Duration;

OmniSocials client = OmniSocials.builder()
    .apiKey("omsk_live_...")
    .baseUrl("https://api.omnisocials.com/v1") // default
    .timeout(Duration.ofSeconds(30))           // per-request timeout (default 30s)
    .maxRetries(2)                             // retries on 429 / 5xx / network errors (default 2)
    .build();
```

## Notes

- Params via `Params.builder().put(...).build()` or `Params.of(k, v, ...)`; unlike `Map.of`, null values are allowed and serialize to JSON `null`
- Responses are Jackson `JsonNode`
- Webhook verification uses `MessageDigest.isEqual` (constant time)
- Source: [github.com/OmniSocials/omnisocials-java](https://github.com/OmniSocials/omnisocials-java)
