# PHP Social Media API SDK

The official PHP SDK. PHP 8.1+, no Composer dependencies (ext-curl, ext-json),
Stripe-style resource objects with associative-array params.

## Install

```bash
composer require omnisocials/omnisocials-php
```

## Authentication

```php
use OmniSocials\Client;

$client = new Client(apiKey: 'omsk_live_...');
```

Reads `OMNISOCIALS_API_KEY` if omitted. A missing key throws
`OmniSocials\Exception\AuthenticationException`.

## Create a post

```php
$response = $client->posts->create([
    'content' => 'New drop this Friday',
    'channels' => ['instagram', 'facebook', 'linkedin'],
    'scheduled_at' => '2026-08-01T09:00:00Z',
    'media_urls' => ['https://example.com/teaser.jpg'],
]);
echo $response['data']['id'] . ' ' . $response['data']['status'];
```

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

```php
'media_urls' => [['url' => 'https://example.com/teaser.jpg', 'alt' => 'Red sneaker on a white background']],
```

Publish immediately:

```php
$client->posts->createAndPublish([
    'content' => 'Going live right now',
    'channels' => ['x', 'bluesky'],
]);
```

## Upload media

```php
$upload = $client->media->uploadFromUrl([
    'url' => 'https://example.com/launch-video.mp4',
    'name' => 'launch-video-v2',
    'folder' => 'Campaigns',
]);
echo $upload['data']['id'];
print_r($upload['compatibility']);
```

Local files via multipart:

```php
// From a path
$client->media->upload(['file' => './photos/product.jpg', 'name' => 'product-hero']);

// Or from raw bytes (pass a filename so the API can detect the type)
$bytes = file_get_contents('./photos/product.jpg');
$client->media->upload(['file' => $bytes, 'filename' => 'product.jpg']);
```

## Analytics

```php
// One post's latest per-platform metrics
$stats = $client->analytics->post('post_id');
print_r($stats['data']['platforms']['instagram']['metrics'] ?? null);

// Batch: up to 100 posts in one call
$batch = $client->analytics->posts(['id1', 'id2', 'id3']);

// Workspace-wide overview
$overview = $client->analytics->overview(['period' => '30d']);
echo $overview['data']['total_impressions'] . ' ' . $overview['data']['total_engagements'];

// Account-level stats (followers etc)
$accountStats = $client->analytics->accounts(['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).

```php
$conversations = $client->inbox->listConversations(['platform' => 'instagram', 'unread' => true]);
$conversationId = $conversations['data'][0]['conversation_id'];

$messages = $client->inbox->getMessages($conversationId);
$client->inbox->reply($conversationId, ['text' => 'Thanks for reaching out!']);
$client->inbox->markRead($conversationId);

// Comment moderation: hide (['hide' => false] unhides) or delete
$client->inbox->hide($messages['data'][0]['id']);
$client->inbox->deleteMessage($messages['data'][0]['id']);
```

`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.

```php
$next = $client->inbox->next();
while ($next['data'] !== null) {
    $message = $next['data']['message'];

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

    $reply = $client->inbox->reply($message['conversation_id'], [
        'text' => 'Thanks! DM sent.',
        'message_id' => $message['id'],
        'include_next' => true,
    ]);
    $next = ['data' => $reply['next'] ?? null, 'remaining' => $reply['remaining'] ?? 0];
}
```

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.

```php
$result = $client->pinterest->listProducts();

if (isset($result['error'])) {
    error_log($result['error']['code'] . ': ' . $result['error']['message']);
} else {
    $client->posts->create([
        'content' => 'Our summer picks',
        'channels' => ['pinterest'],
        'media_urls' => ['https://example.com/summer-look.jpg'],
        'scheduled_at' => '2026-08-01T09:00:00Z',
        'pinterest' => [
            'board_id' => '1234567890',
            'product_tags' => array_column(array_slice($result['products'], 0, 3), 'pin_id'),
        ],
    ]);
}
```

The list answers with HTTP 200 in both cases: `products` on success, or an `error` array
(`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` array replaces the stored one, so leave
`product_tags` out to remove the tags.

## Verify webhooks

```php
<?php

require __DIR__ . '/vendor/autoload.php';

use OmniSocials\Webhooks;
use OmniSocials\Exception\WebhookVerificationException;

$payload = file_get_contents('php://input'); // the raw body, untouched
$signature = $_SERVER['HTTP_X_OMNISOCIALS_SIGNATURE'] ?? '';

try {
    $event = Webhooks::verifySignature(
        $payload,
        $signature,
        getenv('OMNISOCIALS_WEBHOOK_SECRET'),
        300 // tolerance in seconds (default)
    );
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit;
}

switch ($event['type']) {
    case 'post.published':
        error_log('Published: ' . $event['data']['post_id']);
        break;
    case 'post.failed':
        error_log('Failed: ' . $event['data']['post_id']);
        break;
    case 'post.rejected':
        error_log('Rejected: ' . $event['data']['post_id'] . ' ' . ($event['data']['approval']['reason'] ?? ''));
        break;
}

http_response_code(200);
```

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

The base class is `OmniSocials\Exception\OmniSocialsException`. `ApiException`
exposes `getStatus()`, `getErrorCode()`, `getMessage()`, and `getBody()`. Types:
`ValidationException` (400/422), `AuthenticationException` (401),
`PermissionDeniedException` (403), `NotFoundException` (404), `RateLimitException`
(429, `getRetryAfter()`), `ServerException` (5xx), `ApiConnectionException`, and
`WebhookVerificationException`.

```php
use OmniSocials\Exception\ApiConnectionException;
use OmniSocials\Exception\ApiException;
use OmniSocials\Exception\RateLimitException;
use OmniSocials\Exception\ValidationException;

try {
    $client->posts->create(['content' => 'Hi', 'channels' => ['instagram']]);
} catch (RateLimitException $e) {
    echo 'Rate limited, retry in ' . $e->getRetryAfter() . "s\n";
} catch (ValidationException $e) {
    echo "Bad request ({$e->getErrorCode()}): {$e->getMessage()}\n";
    print_r($e->getBody());
} catch (ApiConnectionException $e) {
    echo 'Network problem: ' . $e->getMessage() . "\n";
} catch (ApiException $e) {
    echo "API error {$e->getStatus()} ({$e->getErrorCode()}): {$e->getMessage()}\n";
}
```

Note the error code accessor is `getErrorCode()`, not `getCode()`.

## Configuration

```php
$client = new Client(
    apiKey: 'omsk_live_...',
    baseUrl: 'https://api.omnisocials.com/v1', // default
    timeout: 30.0,   // per-request timeout in seconds (default 30)
    maxRetries: 2,   // automatic retries on 429 / 5xx / network errors (default 2)
);
```

## Notes

- Responses are decoded to associative arrays
- Webhook verification is the static `Webhooks::verifySignature()` using `hash_equals`
- Source: [github.com/OmniSocials/omnisocials-php](https://github.com/OmniSocials/omnisocials-php)
