# Go Social Media API SDK

The official Go SDK. Standard library only, Go 1.21+, context-first API with typed
errors and generics for response envelopes.

## Install

```bash
go get github.com/OmniSocials/omnisocials-go
```

## Authentication

```go
client, err := omnisocials.NewClient(omnisocials.WithAPIKey("omsk_live_..."))
```

Reads `OMNISOCIALS_API_KEY` if no option is passed. A missing key returns
`*omnisocials.AuthenticationError`.

## Create a post

```go
res, err := client.Posts.Create(ctx, &omnisocials.PostCreateParams{
	Content:     "New drop this Friday",
	Channels:    []string{"instagram", "facebook", "linkedin"},
	ScheduledAt: "2026-08-01T09:00:00Z",
	MediaURLs:   []string{"https://example.com/teaser.jpg"},
})
fmt.Println(res.Data.ID, res.Data.Status)
```

`MediaURLs` / `MediaIDs` entries can also carry per-media alt text (max 1500
chars) via `MediaURLEntry` / `MediaIDEntry`, delivered to Mastodon, Bluesky,
X (photos/GIFs), Pinterest, Instagram (images), and LinkedIn (images):

```go
MediaURLs: []omnisocials.MediaURLEntry{
	{URL: "https://example.com/teaser.jpg", Alt: "Red sneaker on a white background"},
},
```

Publish immediately:

```go
res, err := client.Posts.CreateAndPublish(ctx, &omnisocials.PostCreateParams{
	Content:  "Going live right now",
	Channels: []string{"x", "bluesky"},
})
```

## Upload media

```go
upload, err := client.Media.UploadFromURL(ctx, &omnisocials.MediaUploadFromURLParams{
	URL:    "https://example.com/launch-video.mp4",
	Name:   "launch-video-v2",
	Folder: "Campaigns",
})
fmt.Println(upload.Data.ID, upload.Compatibility)
```

Local files via multipart (any `io.Reader`):

```go
file, err := os.Open("./photos/product.jpg")
if err != nil {
	log.Fatal(err)
}
defer file.Close()

res, err := client.Media.Upload(ctx, &omnisocials.MediaUploadParams{
	File:     file,
	Filename: "product.jpg",
	Name:     "product-hero",
})
```

## Analytics

```go
// One post's latest per-platform metrics
stats, err := client.Analytics.Post(ctx, "post_id")
fmt.Println(stats.Data.Platforms["instagram"].Metrics)

// Batch: up to 100 posts in one call
batch, err := client.Analytics.Posts(ctx, []string{"id1", "id2", "id3"})

// Workspace-wide overview
overview, err := client.Analytics.Overview(ctx, &omnisocials.AnalyticsOverviewParams{Period: "30d"})
fmt.Println(overview.Data.TotalImpressions, overview.Data.TotalEngagement)

// Account-level stats (followers etc)
accountStats, err := client.Analytics.Accounts(ctx, &omnisocials.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.NextCursor` out).

```go
conversations, err := client.Inbox.ListConversations(ctx, &omnisocials.InboxListParams{
	Platform: "instagram",
	Unread:   omnisocials.Bool(true),
})
conversationID := conversations.Data[0].ConversationID

thread, err := client.Inbox.GetMessages(ctx, conversationID, nil)
_, err = client.Inbox.Reply(ctx, conversationID, &omnisocials.InboxReplyParams{
	Text: "Thanks for reaching out!",
})
_, err = client.Inbox.MarkRead(ctx, conversationID)

// Comment moderation: hide (false unhides) or delete
_, err = client.Inbox.Hide(ctx, thread.Data[0].ID, true)
_, err = client.Inbox.DeleteMessage(ctx, thread.Data[0].ID)
```

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

```go
next, err := client.Inbox.Next(ctx, &omnisocials.InboxNextParams{})
for err == nil && next.Data != nil {
	if !next.Data.ReplyWindow.Open {
		// An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app
		if _, err = client.Inbox.MarkRead(ctx, next.Data.Message.ConversationID); err == nil {
			next, err = client.Inbox.Next(ctx, &omnisocials.InboxNextParams{})
		}
		continue
	}

	var reply *omnisocials.InboxReplyResponse
	reply, err = client.Inbox.Reply(ctx, next.Data.Message.ConversationID, &omnisocials.InboxReplyParams{
		Text:        "Thanks! DM sent.",
		MessageID:   next.Data.Message.ID,
		IncludeNext: true,
	})
	if err == nil {
		next = &omnisocials.InboxNextResponse{Data: reply.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.

```go
list, err := client.Pinterest.ListProducts(ctx, nil)
if err != nil {
	log.Fatal(err)
}

if list.Error != nil {
	log.Println(list.Error.Code, list.Error.Message)
} else {
	productTags := []string{}
	for _, product := range list.Products {
		if len(productTags) < 3 {
			productTags = append(productTags, product.PinID)
		}
	}

	_, err = client.Posts.Create(ctx, &omnisocials.PostCreateParams{
		Content:     "Our summer picks",
		Channels:    []string{"pinterest"},
		MediaURLs:   []string{"https://example.com/summer-look.jpg"},
		ScheduledAt: "2026-08-01T09:00:00Z",
		Pinterest:   map[string]any{"board_id": "1234567890", "product_tags": productTags},
	})
}
```

The list answers with HTTP 200 in both cases: `Products` on success, or an `Error` with
a `Code` (`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(ctx, 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

```go
package main

import (
	"errors"
	"io"
	"log"
	"net/http"
	"os"
	"time"

	omnisocials "github.com/OmniSocials/omnisocials-go"
)

func main() {
	secret := os.Getenv("OMNISOCIALS_WEBHOOK_SECRET")

	http.HandleFunc("/omnisocials/webhook", func(w http.ResponseWriter, r *http.Request) {
		payload, err := io.ReadAll(r.Body) // keep the raw body!
		if err != nil {
			http.Error(w, "failed to read body", http.StatusBadRequest)
			return
		}

		event, err := omnisocials.VerifyWebhookSignature(
			payload,
			r.Header.Get("X-OmniSocials-Signature"),
			secret,
			5*time.Minute, // tolerance; pass 0 for the default (5 minutes)
		)
		if err != nil {
			var verr *omnisocials.WebhookVerificationError
			if errors.As(err, &verr) {
				http.Error(w, "invalid signature", http.StatusBadRequest)
				return
			}
			http.Error(w, "server error", http.StatusInternalServerError)
			return
		}

		data, _ := event["data"].(map[string]any)
		switch event["type"] {
		case "post.published":
			log.Println("Published:", data["post_id"], data["targets"])
		case "post.failed":
			log.Println("Failed:", data["post_id"])
		case "post.rejected":
			approval, _ := data["approval"].(map[string]any)
			log.Println("Rejected:", data["post_id"], approval["reason"])
		}
		w.WriteHeader(http.StatusOK)
	})

	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

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

## Errors

Typed errors matched with `errors.As`. The `*APIError` wrapper has `Status`,
`Code`, `Message`, and `Body`. Types: `*ValidationError` (400/422),
`*AuthenticationError` (401), `*PermissionDeniedError` (403), `*NotFoundError`
(404), `*RateLimitError` (429, `RetryAfter` is a `time.Duration`), `*ServerError`
(5xx), `*ConnectionError`, and `*WebhookVerificationError`.

```go
_, err := client.Posts.Create(ctx, &omnisocials.PostCreateParams{
	Content:  "Hi",
	Channels: []string{"instagram"},
})
if err != nil {
	var rateLimited *omnisocials.RateLimitError
	var validation *omnisocials.ValidationError
	var connection *omnisocials.ConnectionError
	var apiErr *omnisocials.APIError

	switch {
	case errors.As(err, &rateLimited):
		log.Printf("rate limited, retry in %s", rateLimited.RetryAfter)
	case errors.As(err, &validation):
		log.Printf("bad request (%s): %s", validation.Code, validation.Message)
	case errors.As(err, &connection):
		log.Printf("network problem: %v", connection)
	case errors.As(err, &apiErr):
		log.Printf("API error %d (%s): %s", apiErr.Status, apiErr.Code, apiErr.Message)
	default:
		log.Fatal(err)
	}
}
```

## Configuration

```go
client, err := omnisocials.NewClient(
	omnisocials.WithAPIKey("omsk_live_..."),
	omnisocials.WithBaseURL("https://api.omnisocials.com/v1"), // default
	omnisocials.WithTimeout(30*time.Second),                   // per-request timeout (default 30s)
	omnisocials.WithMaxRetries(2),                             // retries on 429 / 5xx / network errors (default 2)
	omnisocials.WithHTTPClient(customHTTPClient),              // optional custom *http.Client
)
```

## Notes

- Every method takes a `context.Context` as its first argument for deadlines and cancellation
- Generic response envelopes: `ItemResponse[T]` and `ListResponse[T]`
- Nullable helpers: `omnisocials.Bool(false)`, `omnisocials.String(...)`, and the `omnisocials.Null` sentinel for clearing fields
- Source: [github.com/OmniSocials/omnisocials-go](https://github.com/OmniSocials/omnisocials-go)
