# 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), and Pinterest:

```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?;
```

## 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"]);
                }
                _ => {}
            }
            StatusCode::OK
        }
        Err(_) => StatusCode::BAD_REQUEST,
    }
}

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

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