# Rust Social Media API SDK

The official Rust SDK. Async-first on `reqwest` with rustls (no OpenSSL), typed
request structs with serde, `serde_json::Value` responses.

## Install

```bash
cargo add omnisocials
cargo add tokio --features rt-multi-thread,macros
```

## Authentication

```rust
let client = omnisocials::Client::new("omsk_live_...")?;
```

Or `Client::from_env()?` (reads `OMNISOCIALS_API_KEY`). A missing key returns
`Error::Auth` with code `missing_api_key`.

## Create a post

```rust
use omnisocials::CreatePostParams;

let post = client.posts().create(CreatePostParams {
    content: "New drop this Friday".into(),
    channels: Some(vec!["instagram".into(), "facebook".into(), "linkedin".into()]),
    scheduled_at: Some("2026-08-01T09:00:00Z".into()),
    media_urls: Some(vec!["https://example.com/teaser.jpg"].into()),
    ..Default::default()
}).await?;
println!("{} {}", post["data"]["id"], post["data"]["status"]);
```

`media_urls` / `media_ids` entries can also carry per-media alt text (max 1500
chars) via `MediaEntry::Url` / `MediaEntry::Id`, delivered to Mastodon, Bluesky,
X (photos/GIFs), Pinterest, Instagram (images), and LinkedIn (images):

```rust
use omnisocials::MediaEntry;

media_urls: Some(vec![MediaEntry::Url {
    url: "https://example.com/teaser.jpg".into(),
    alt: Some("Red sneaker on a white background".into()),
}].into()),
```

Publish immediately:

```rust
client.posts().create_and_publish(CreatePostParams {
    content: "Going live right now".into(),
    channels: Some(vec!["x".into(), "bluesky".into()]),
    ..Default::default()
}).await?;
```

## Upload media

```rust
use omnisocials::UploadMediaFromUrlParams;

let upload = client.media().upload_from_url(UploadMediaFromUrlParams {
    url: "https://example.com/launch-video.mp4".into(),
    name: Some("launch-video-v2".into()),
    folder: Some("Campaigns".into()),
    ..Default::default()
}).await?;
println!("{} {:?}", upload["data"]["id"], upload["compatibility"]);
```

Local files (multipart from bytes):

```rust
use omnisocials::UploadMediaParams;

let bytes = std::fs::read("./photos/product.jpg")?;
let uploaded = client.media().upload(UploadMediaParams {
    file: bytes,
    filename: "product.jpg".into(),
    name: Some("product-hero".into()),
    ..Default::default()
}).await?;
```

## Analytics

```rust
use omnisocials::{AccountAnalyticsParams, AnalyticsOverviewParams};

// One post's latest per-platform metrics
let stats = client.analytics().post("post_id").await?;
println!("{:?}", stats["data"]["platforms"]["instagram"]["metrics"]);

// Batch: up to 100 posts in one call
let batch = client.analytics().posts(&["id1", "id2", "id3"]).await?;

// Workspace-wide overview
let overview = client.analytics().overview(AnalyticsOverviewParams {
    period: Some("30d".into()),
    ..Default::default()
}).await?;
println!(
    "{} impressions, {} engagements",
    overview["data"]["total_impressions"], overview["data"]["total_engagements"]
);

// Account-level stats (followers etc)
let account_stats = client.analytics().accounts(AccountAnalyticsParams {
    platform: Some("instagram".into()),
    ..Default::default()
}).await?;
```

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

```rust
use omnisocials::{ListConversationsParams, ReplyParams};

let conversations = client.inbox().list_conversations(ListConversationsParams {
    platform: Some("instagram".into()),
    unread: Some(true),
    ..Default::default()
}).await?;
let conversation_id = conversations["data"][0]["conversation_id"].as_str().unwrap();

let messages = client.inbox().get_messages(conversation_id, Default::default()).await?;
client.inbox().reply(conversation_id, ReplyParams {
    text: "Thanks for reaching out!".into(),
    ..Default::default()
}).await?;
client.inbox().mark_read(conversation_id).await?;

// Comment moderation: hide (false unhides) or delete
let message_id = messages["data"][0]["id"].as_str().unwrap();
client.inbox().hide(message_id, true).await?;
client.inbox().delete_message(message_id).await?;
```

`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, set `message_id` so the reply
goes under the comment you answer and not under the newest comment on the post.

```rust
use omnisocials::NextUnansweredParams;

let mut next = client.inbox().next(NextUnansweredParams::default()).await?;
while !next["data"].is_null() {
    let message = &next["data"]["message"];

    if next["data"]["reply_window"]["open"] == false {
        // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app
        client.inbox().mark_read(message["conversation_id"].as_str().unwrap()).await?;
        next = client.inbox().next(NextUnansweredParams::default()).await?;
        continue;
    }

    let reply = client.inbox().reply(message["conversation_id"].as_str().unwrap(), ReplyParams {
        text: "Thanks! DM sent.".into(),
        message_id: message["id"].as_str().map(str::to_owned),
        include_next: Some(true),
        ..Default::default()
    }).await?;
    next = serde_json::json!({ "data": reply["next"], "remaining": reply["remaining"] });
}
```

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` options of the post. Only product Pins of your own
account can be tagged; products of other merchants cannot.

```rust
use omnisocials::ListPinterestProductsParams;

let result = client.pinterest().list_products(ListPinterestProductsParams::default()).await?;

if let Some(error) = result.get("error") {
    eprintln!("{}: {}", error["code"], error["message"]);
} else {
    let product_tags: Vec<&str> = result["products"]
        .as_array()
        .map(|products| products.iter().filter_map(|product| product["pin_id"].as_str()).take(3).collect())
        .unwrap_or_default();

    client.posts().create(CreatePostParams {
        content: "Our summer picks".into(),
        channels: Some(vec!["pinterest".into()]),
        media_urls: Some(vec!["https://example.com/summer-look.jpg"].into()),
        scheduled_at: Some("2026-08-01T09:00:00Z".into()),
        pinterest: Some(json!({ "board_id": "1234567890", "product_tags": product_tags })),
        ..Default::default()
    }).await?;
}
```

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().validate_product(id)` checks one Pin id or Pin link before
you post. On `posts().update` the `pinterest` value replaces the stored one, so leave
`product_tags` out to remove the tags.

## Verify webhooks

axum handler:

```rust
use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::Router;

async fn omnisocials_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let signature = headers
        .get("x-omnisocials-signature")
        .and_then(|value| value.to_str().ok())
        .unwrap_or("");
    let secret = std::env::var("OMNISOCIALS_WEBHOOK_SECRET").expect("secret not set");

    // `body` is the raw request bytes: exactly what the signature covers.
    match omnisocials::webhooks::verify_signature(&body, signature, &secret, 300) {
        Ok(event) => {
            match event["type"].as_str() {
                Some("post.published") => {
                    println!("Published: {} {:?}", event["data"]["post_id"], event["data"]["targets"]);
                }
                Some("post.failed") => {
                    eprintln!("Failed: {}", event["data"]["post_id"]);
                }
                Some("post.rejected") => {
                    eprintln!("Rejected: {} {}", event["data"]["post_id"], event["data"]["approval"]["reason"]);
                }
                _ => {}
            }
            StatusCode::OK
        }
        Err(_) => StatusCode::BAD_REQUEST,
    }
}

let app: Router = Router::new().route("/omnisocials/webhook", post(omnisocials_webhook));
```

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().get_approval(id)`.

## Errors

Everything returns `Result<serde_json::Value, omnisocials::Error>`. The `Error`
enum has variants `Validation` (400/422), `Auth` (401), `PermissionDenied` (403),
`NotFound` (404), `RateLimit` (429, with `retry_after`), `Server` (5xx), `Api`
(other non-2xx, e.g. 409 `media_in_use`), `Connection`, and `WebhookVerification`.
Accessors `err.status()`, `err.code()`, and `err.retry_after()` work across all
API variants.

```rust
use omnisocials::{CreatePostParams, Error};

match client.posts().create(CreatePostParams {
    content: "Hi".into(),
    channels: Some(vec!["instagram".into()]),
    ..Default::default()
}).await {
    Ok(post) => println!("created {}", post["data"]["id"]),
    Err(Error::RateLimit { retry_after, .. }) => {
        eprintln!("rate limited, retry in {:?}s", retry_after);
    }
    Err(Error::Validation { code, message, .. }) => {
        eprintln!("bad request ({:?}): {}", code, message);
    }
    Err(Error::Connection(err)) => {
        eprintln!("network problem: {err}");
    }
    Err(err) => {
        eprintln!("API error {:?} ({:?}): {}", err.status(), err.code(), err);
    }
}
```

## Configuration

```rust
use std::time::Duration;

let client = omnisocials::Client::builder()
    .api_key("omsk_live_...")                       // wins over the env var
    .base_url("https://api.omnisocials.com/v1")     // default
    .timeout(Duration::from_secs(30))               // per-request timeout (default 30s)
    .max_retries(2)                                 // retries on 429 / 5xx / network errors (default 2)
    .build()?;
```

## Notes

- Typed request structs with the `..Default::default()` idiom throughout
- `content` is a `Content` enum with a `Content::PerPlatform(HashMap<...>)` variant for per-platform text
- Clearing nullable fields uses nested options: `folder_id: Some(Some("12".into()))` sets a value, `Some(None)` sends JSON `null` (move to root)
- Responses are `serde_json::Value`; deserialize with `serde_json::from_value` if you want types
- Source: [github.com/OmniSocials/omnisocials-rust](https://github.com/OmniSocials/omnisocials-rust)
