# .NET Social Media API SDK

The official .NET SDK. Targets .NET 8, depends only on `System.Net.Http` and
`System.Text.Json`, fully async with `CancellationToken` support on every method.

## Install

```bash
dotnet add package OmniSocials
```

## Authentication

```csharp
var client = new OmniSocialsClient(new OmniSocialsOptions { ApiKey = "omsk_live_..." });
```

Reads `OMNISOCIALS_API_KEY` if omitted. A missing key throws
`AuthenticationException`. The client owns an internal `HttpClient` and implements
`IDisposable`: create one and reuse it.

## Create a post

```csharp
var response = await client.Posts.CreateAsync(new PostCreateParams
{
    Content = "New drop this Friday",
    Channels = new[] { "instagram", "facebook", "linkedin" },
    ScheduledAt = "2026-08-01T09:00:00Z",
    MediaUrls = new[] { "https://example.com/teaser.jpg" },
});
var post = response!.Value.GetProperty("data");
Console.WriteLine($"{post.GetProperty("id")} {post.GetProperty("status")}");
```

`MediaUrls` / `MediaIds` entries can also be objects carrying per-media alt text
(`new { url, alt }` / `new { id, alt }`, max 1500 chars), delivered to Mastodon,
Bluesky, X (photos/GIFs), Pinterest, Instagram (images), and LinkedIn (images):

```csharp
MediaUrls = new object[] { new { url = "https://example.com/teaser.jpg", alt = "Red sneaker on a white background" } },
```

Publish immediately:

```csharp
await client.Posts.CreateAndPublishAsync(new PostCreateParams
{
    Content = "Going live right now",
    Channels = new[] { "x", "bluesky" },
});
```

## Upload media

```csharp
var upload = await client.Media.UploadFromUrlAsync(new MediaUploadFromUrlParams
{
    Url = "https://example.com/launch-video.mp4",
    Name = "launch-video-v2",
    Folder = "Campaigns",
});
Console.WriteLine(upload!.Value.GetProperty("data").GetProperty("id"));
```

Local files via factory methods:

```csharp
// From a path
await client.Media.UploadAsync(MediaUploadParams.FromFile("./photos/product.jpg", name: "product-hero"));

// Or from bytes / a stream
var bytes = await File.ReadAllBytesAsync("./photos/product.jpg");
await client.Media.UploadAsync(MediaUploadParams.FromBytes(bytes, "product.jpg"));

await using var stream = File.OpenRead("./photos/product.jpg");
await client.Media.UploadAsync(MediaUploadParams.FromStream(stream, "product.jpg"));
```

## Analytics

```csharp
// One post's latest per-platform metrics
var stats = await client.Analytics.PostAsync("post_id");
Console.WriteLine(stats!.Value.GetProperty("data").GetProperty("platforms"));

// Batch: up to 100 posts in one call
var batch = await client.Analytics.PostsAsync(new[] { "id1", "id2", "id3" });

// Workspace-wide overview
var overview = await client.Analytics.OverviewAsync(new AnalyticsOverviewParams { Period = "30d" });
var totals = overview!.Value.GetProperty("data");
Console.WriteLine($"{totals.GetProperty("total_impressions")} impressions");

// Account-level stats (followers etc)
var accountStats = await client.Analytics.AccountsAsync(new AccountAnalyticsParams { 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). Optional typed models are available
(`InboxNextResponse`, `InboxReplyResponse`, `InboxDeleteMessageResponse`).

```csharp
var page = await client.Inbox.ListConversationsAsync(new InboxConversationListParams
{
    Platform = "instagram",
    Unread = true,
});
var conversationId = page!.Value.GetProperty("data")[0].GetProperty("conversation_id").GetString()!;

var messages = await client.Inbox.GetMessagesAsync(conversationId);
await client.Inbox.ReplyAsync(conversationId, new InboxReplyParams { Text = "Thanks for reaching out!" });
await client.Inbox.MarkReadAsync(conversationId);

// Comment moderation: hide (hide: false unhides) or delete
var messageId = messages!.Value.GetProperty("data")[0].GetProperty("id").GetString()!;
await client.Inbox.HideAsync(messageId);
await client.Inbox.DeleteMessageAsync(messageId);
```

`NextAsync` hands out the next conversation that still needs an answer, with the
thread and the post it belongs to. On a comment thread, set `MessageId` so the reply
goes under the comment you answer and not under the newest comment on the post.

```csharp
var next = await client.Inbox.NextAsync();
var item = next!.Value.GetProperty("data");
while (item.ValueKind != JsonValueKind.Null)
{
    var message = item.GetProperty("message");

    if (!item.GetProperty("reply_window").GetProperty("open").GetBoolean())
    {
        // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app
        await client.Inbox.MarkReadAsync(message.GetProperty("conversation_id").GetString()!);
        next = await client.Inbox.NextAsync();
        item = next!.Value.GetProperty("data");
        continue;
    }

    var reply = await client.Inbox.ReplyAsync(message.GetProperty("conversation_id").GetString()!, new InboxReplyParams
    {
        Text = "Thanks! DM sent.",
        MessageId = message.GetProperty("id").GetString(),
        IncludeNext = true,
    });
    item = reply!.Value.GetProperty("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` options of the post. Only product Pins of your own
account can be tagged; products of other merchants cannot.

```csharp
var result = (await client.Pinterest.ListProductsAsync())!.Value;

if (result.TryGetProperty("error", out var error))
{
    Console.Error.WriteLine(error.GetProperty("code").GetString());
}
else
{
    var productTags = result.GetProperty("products").EnumerateArray()
        .Take(3)
        .Select(product => product.GetProperty("pin_id").GetString()!)
        .ToArray();

    await client.Posts.CreateAsync(new PostCreateParams
    {
        Content = "Our summer picks",
        Channels = new[] { "pinterest" },
        MediaUrls = new[] { "https://example.com/summer-look.jpg" },
        ScheduledAt = "2026-08-01T09:00:00Z",
        Pinterest = new Dictionary<string, object?>
        {
            ["board_id"] = "1234567890",
            ["product_tags"] = productTags,
        },
    });
}
```

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.ValidateProductAsync(id)` checks one Pin id or Pin link
before you post. On `Posts.UpdateAsync` the `Pinterest` dictionary replaces the stored
one, so leave `["product_tags"]` out to remove the tags.

## Verify webhooks

ASP.NET Core minimal API:

```csharp
using System.Text.Json;
using OmniSocials;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/omnisocials/webhook", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body);
    var payload = await reader.ReadToEndAsync(); // raw body, do not re-serialize
    var signature = request.Headers["X-OmniSocials-Signature"].ToString();
    var secret = Environment.GetEnvironmentVariable("OMNISOCIALS_WEBHOOK_SECRET")!;

    JsonElement webhookEvent;
    try
    {
        webhookEvent = WebhookSignature.Verify(payload, signature, secret, toleranceSeconds: 300);
    }
    catch (WebhookVerificationException)
    {
        return Results.BadRequest();
    }

    switch (webhookEvent.GetProperty("type").GetString())
    {
        case "post.published":
            var data = webhookEvent.GetProperty("data");
            Console.WriteLine($"Published: {data.GetProperty("post_id")} {data.GetProperty("targets")}");
            break;
        case "post.failed":
            Console.Error.WriteLine($"Failed: {webhookEvent.GetProperty("data").GetProperty("post_id")}");
            break;
        case "post.rejected":
            var rejected = webhookEvent.GetProperty("data");
            Console.Error.WriteLine($"Rejected: {rejected.GetProperty("post_id")} {rejected.GetProperty("approval").GetProperty("reason")}");
            break;
    }
    return Results.Ok();
});

app.Run();
```

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

## Errors

All exceptions extend `OmniSocialsException`. `ApiException` exposes `Status`,
`Code`, `Message`, and `Body`. Types: `ValidationException` (400/422),
`AuthenticationException` (401), `PermissionDeniedException` (403),
`NotFoundException` (404), `RateLimitException` (429, `RetryAfter`),
`ServerException` (5xx), `ApiConnectionException`, `WebhookVerificationException`.

```csharp
try
{
    await client.Posts.CreateAsync(new PostCreateParams
    {
        Content = "Hi",
        Channels = new[] { "instagram" },
    });
}
catch (RateLimitException ex)
{
    Console.WriteLine($"Rate limited, retry in {ex.RetryAfter}s");
}
catch (ValidationException ex)
{
    Console.WriteLine($"Bad request ({ex.Code}): {ex.Message}");
}
catch (ApiConnectionException ex)
{
    Console.WriteLine($"Network problem: {ex.Message}");
}
catch (ApiException ex)
{
    Console.WriteLine($"API error {ex.Status} ({ex.Code}): {ex.Message}");
}
```

## Configuration

```csharp
var client = new OmniSocialsClient(new OmniSocialsOptions
{
    ApiKey = "omsk_live_...",
    BaseUrl = "https://api.omnisocials.com/v1", // default
    Timeout = TimeSpan.FromSeconds(30),          // per-request timeout (default 30s)
    MaxRetries = 2,                              // automatic retries on 429 / 5xx / network errors (default 2)
});
```

`Timeout` is a `TimeSpan` and applies per attempt.

## Notes

- Every method ends in `...Async` and returns `Task<JsonElement?>` (raw JSON)
- Update helpers: `MoveToRoot = true` (media) / `MoveToTopLevel = true` (folders) distinguish "leave unchanged" from "set to null"
- Dictionary entries with explicit nulls are sent on the wire; null POCO properties are omitted
- Source: [github.com/OmniSocials/omnisocials-dotnet](https://github.com/OmniSocials/omnisocials-dotnet)
