Search by

postproxy / postproxy-php

postproxy

PHP client for the Postproxy API — manage social media posts, profiles, and profile groups.

Package info

github.com/postproxy/postproxy-php

Homepage

pkg:composer/postproxy/postproxy-php

Statistics

Installs: 942

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.15.0 2026-10-30 09:51 UTC

README

PHP client for the Postproxy API — manage social media posts, profiles, and profile groups.

Requirements

  • PHP >= 8.1
  • Composer

Installation

composer require postproxy/postproxy-php

Quick Start

use PostProxy\Client;

$client = new Client(apiKey: 'your-api-key');

// List profiles
$profiles = $client->profiles()->list();

// Create a post
$post = $client->posts()->create(
    'Hello world!',
    profiles: ['prof-1'],
);

Configuration

$client = new Client(
    apiKey: 'your-api-key',
    profileGroupId: 'pg-123',  // Default profile group for all requests
);

Idempotency

Every write method (POST/PUT/PATCH/DELETE) accepts an idempotencyKey:, sent as the Idempotency-Key header. If the connection drops before you see the response, retry with the same key and you get the original response back instead of a second post:

$key = bin2hex(random_bytes(16));

$post = $client->posts()->create('Hello', profiles: ['profile-id'], idempotencyKey: $key);

// Retrying the same call with the same key replays the original response.

Generate a fresh key per logical operation — a UUID is ideal. Keys are scoped to your account and may be up to 255 characters. The SDK never generates keys or retries for you.

Situation Result
First request with the key Runs normally
Retry after a success Original status and body replayed
Retry while the first is still running ConflictException (409) — wait and retry
Same key, different request body ValidationException (422)
Retry after an error response Runs normally — errors are not replayed

Only successful (2xx) responses are stored, so a request that failed validation or hit a quota leaves the key free — fix the payload and retry with the same key. Stored responses are kept for 24 hours. Requests without a key are unaffected.

Resources

Posts

// List posts with filters
$result = $client->posts()->list(page: 1, perPage: 10, status: 'processed');
$result->data;    // Post[]
$result->total;   // int
$result->page;    // int
$result->perPage; // int

// Get a single post
$post = $client->posts()->get('post-id');

// Create a post
$post = $client->posts()->create(
    'Post body',
    profiles: ['prof-1', 'prof-2'],
    media: ['https://example.com/image.jpg'],
    scheduledAt: '2025-06-01T12:00:00Z',
    draft: true,
);

// Create a post with file uploads
$post = $client->posts()->create(
    'Post with uploads',
    profiles: ['prof-1'],
    mediaFiles: ['/path/to/image.jpg'],
);

// Create a thread post
$post = $client->posts()->create(
    'Thread starts here',
    profiles: ['prof-1'],
    thread: [
        ['body' => 'Second post in the thread'],
        ['body' => 'Third with media', 'media' => ['https://example.com/img.jpg']],
    ],
);
foreach ($post->thread as $child) {
    echo "{$child->id}: {$child->body}\n";
    // Each thread item is delivered separately
    foreach ($child->platforms as $p) {
        echo "  {$p->platform}: {$p->status}\n";
    }
}

// Media items may be URL strings or ['url' => ...] arrays
$post = $client->posts()->create('Two photos', profiles: ['prof-1'], media: [
    'https://example.com/1.jpg',
    ['url' => 'https://example.com/2.jpg'],
]);

// Publish even if an identical post was just submitted (skips the 409)
$post = $client->posts()->create('Same again', profiles: ['prof-1'], skipDuplicateCheck: true);

// Each platform result says where it went
foreach ($post->platforms as $p) {
    echo "{$p->platform} via {$p->profileName}: {$p->permalink}\n";
}

// Edit a post. On a *published* post only the body, platforms.youtube.title,
// platforms.pinterest.title and platforms.pinterest.destination_link can change;
// the edit is pushed to the platforms in the background and reported by the
// platform_post.edited / platform_post.edit_failed webhooks.
$post = $client->posts()->update('post-id',
    body: 'Launch day! (now with the right link)',
    platforms: ['pinterest' => ['destination_link' => 'https://example.com/launch']],
);

// Publish a draft
$post = $client->posts()->publishDraft('post-id');

// Delete a post
$result = $client->posts()->delete('post-id');

// Delete a post and also remove it from social platforms
$result = $client->posts()->delete('post-id', deleteOnPlatform: true);

// Delete from platforms only (keeps DB record). Defaults to all platforms.
$r1 = $client->posts()->deleteOnPlatform('post-id');
// Target a single network
$r2 = $client->posts()->deleteOnPlatform('post-id', network: 'twitter');
// Target a specific profile
$r3 = $client->posts()->deleteOnPlatform('post-id', profileId: 'prof-abc');
// Target a specific post profile (covers entire thread for that profile)
$r4 = $client->posts()->deleteOnPlatform('post-id', postProfileId: 'pp-abc');

// Get stats for posts
$stats = $client->posts()->stats(['post-1', 'post-2']);
foreach ($stats->data as $postId => $postStats) {
    foreach ($postStats->platforms as $platform) {
        echo "{$platform->platform}: " . count($platform->records) . " snapshots\n";
        foreach ($platform->records as $record) {
            echo "  {$record->recordedAt->format('Y-m-d')}: " . json_encode($record->stats) . "\n";
        }
    }
}

// Filter stats by profiles/networks and time range
$stats = $client->posts()->stats(
    ['post-1'],
    profiles: ['instagram', 'twitter'],
    from: '2026-02-01T00:00:00Z',
    to: '2026-02-24T00:00:00Z',
);

Queues

// List all queues
$queues = $client->queues()->list();

// Get a queue
$queue = $client->queues()->get('queue-id');

// Get next available slot
$nextSlot = $client->queues()->nextSlot('queue-id');
echo $nextSlot->nextSlot;

// Create a queue with timeslots
$queue = $client->queues()->create(
    'Morning Posts',
    'profile-group-id',
    description: 'Weekday morning content',
    timezone: 'America/New_York',
    jitter: 10,
    timeslots: [
        ['day' => 1, 'time' => '09:00'],
        ['day' => 2, 'time' => '09:00'],
        ['day' => 3, 'time' => '09:00'],
    ],
);

// Update a queue
$queue = $client->queues()->update('queue-id',
    jitter: 15,
    timeslots: [
        ['day' => 6, 'time' => '10:00'],        // add new timeslot
        ['id' => 1, '_destroy' => true],          // remove existing timeslot
    ],
);

// Pause/unpause a queue
$client->queues()->update('queue-id', enabled: false);

// Delete a queue
$client->queues()->delete('queue-id');

// Add a post to a queue
$post = $client->posts()->create(
    'This post will be scheduled by the queue',
    profiles: ['prof-1'],
    queueId: 'queue-id',
    queuePriority: 'high',
);

Webhooks

// List webhooks
$webhooks = $client->webhooks()->list();

// Get a webhook
$webhook = $client->webhooks()->get('wh-id');

// Create a webhook
$webhook = $client->webhooks()->create(
    'https://example.com/webhook',
    events: ['post.published', 'post.failed'],
    description: 'My webhook',
);
echo $webhook->secret;

// Update a webhook
$webhook = $client->webhooks()->update('wh-id', events: ['post.published'], enabled: false);

// Delete a webhook
$client->webhooks()->delete('wh-id');

// List deliveries
$deliveries = $client->webhooks()->deliveries('wh-id', page: 1, perPage: 10);
foreach ($deliveries->data as $d) {
    echo "{$d->eventType}: {$d->success}\n";
}

Signature verification

Verify incoming webhook signatures using HMAC-SHA256:

use PostProxy\WebhookSignature;

$isValid = WebhookSignature::verify(
    payload: $request->getContent(),
    signatureHeader: $request->headers->get('X-PostProxy-Signature'),
    secret: 'whsec_...',
);

Event types and typed payloads

Subscribe to any of these events (or pass ["*"] for all):

post.processed, post.imported, platform_post.published, platform_post.failed, platform_post.failed_waiting_for_retry, platform_post.edited, platform_post.edit_failed, platform_post.insights, profile.connected, profile.disconnected, profile.stats, profile.placements_synced, placement.orphaned, placement.restored, media.failed, comment.created, comment.failed, comment.failed_waiting_for_retry, comment.delivery_unconfirmed, comment.edited, comment.edit_failed, profile_comment.created, profile_comment.failed, profile_comment.failed_waiting_for_retry, profile_comment.edited, profile_comment.deleted, message.received, message.sent, message.delivered, message.read, message.edited, message.deleted, message.failed_waiting_for_retry, message.failed, reaction.received, referral.received, chat.thread_control_changed, chat.thread_control_requested, message_template.status_updated, message_template.category_updated, placement.name_status_updated, placement.quality_updated, whatsapp.automatic_event.

The full list is also available as Constants::WEBHOOK_EVENT_TYPES.

WebhookEvents::parse validates the envelope and returns a typed Event — $event->data is the right model for the event. Direct-message events share three reusable payload shapes: the eight message.* events decode to MessageEventData (which wraps a Message), reaction.received decodes to ReactionEventData, and the profile_comment.* events decode to ProfileCommentCreatedData. The comment.* events share CommentCreatedData (with error / errorDetails set on failures), the platform_post.* events share PlatformPostData, the placement.* events decode to PlacementEventData, profile.placements_synced to PlacementsSyncedData, referral.received to ReferralEventData, chat.thread_control_changed / chat.thread_control_requested to ThreadControlChangedEventData / ThreadControlRequestedEventData, the message_template.* events to MessageTemplateEventData, and whatsapp.automatic_event to WhatsAppAutomaticEventData:

use PostProxy\WebhookEvents;
use PostProxy\Types\WebhookEvents\ProfileStatsData;
use PostProxy\Types\WebhookEvents\PlatformPostData;
use PostProxy\Types\WebhookEvents\CommentCreatedData;
use PostProxy\Types\WebhookEvents\MessageEventData;
use PostProxy\Types\WebhookEvents\ReactionEventData;
use PostProxy\Types\WebhookEvents\ProfileCommentCreatedData;
use PostProxy\Types\WebhookEvents\MessageTemplateEventData;
use PostProxy\Types\WebhookEvents\ProfileEventData;
use PostProxy\Types\WebhookEvents\ThreadControlChangedEventData;

$event = WebhookEvents::parse($request->getContent());
match ($event->type) {
    'profile.stats' => /** @var ProfileStatsData $d */ $d = $event->data,
    'platform_post.published' => /** @var PlatformPostData $d */ $d = $event->data,
    'comment.created' => /** @var CommentCreatedData $d */ $d = $event->data,
    'profile_comment.created' => /** @var ProfileCommentCreatedData $d */ $d = $event->data,
    'message.received', 'message.sent' => /** @var MessageEventData $d */ $d = $event->data, // $d->message is a Message
    'reaction.received' => /** @var ReactionEventData $d */ $d = $event->data,
    'platform_post.edit_failed' => /** @var PlatformPostData $d */ $d = $event->data, // $d->error
    'message_template.status_updated' => /** @var MessageTemplateEventData $d */ $d = $event->data, // $d->status
    'chat.thread_control_changed' => /** @var ThreadControlChangedEventData $d */ $d = $event->data, // $d->newOwnerAppId, $d->chat
    'profile.disconnected' => /** @var ProfileEventData $d */ $d = $event->data, // $d->origin, $d->error, $d->errorDetails
    default => null,
};

Comments

// List comments on a post (paginated)
$comments = $client->comments()->list('post-id', profileId: 'profile-id');
foreach ($comments->data as $comment) {
    echo "{$comment->authorUsername}: {$comment->body}\n";

    // Media attachments on the comment (image/video/gif/external/file).
    foreach ($comment->attachments as $att) {
        echo "  attachment: {$att->type} -> {$att->url}\n";
    }

    // Author signals (verification, follower count, ...) when the platform provides them.
    if ($comment->metadata !== null) {
        echo "  metadata: " . json_encode($comment->metadata) . "\n";
    }

    foreach ($comment->replies as $reply) {
        echo "  {$reply->authorUsername}: {$reply->body}\n";
    }
}

// List with pagination
$comments = $client->comments()->list('post-id', profileId: 'profile-id', page: 2, perPage: 10);

// Get a single comment
$comment = $client->comments()->get('post-id', 'comment-id', profileId: 'profile-id');

// Create a comment
$comment = $client->comments()->create('post-id', profileId: 'profile-id', text: 'Great post!');

// Reply to a comment
$reply = $client->comments()->create('post-id', profileId: 'profile-id', text: 'Thanks!', parentId: 'comment-id');

// Delete a comment
$result = $client->comments()->delete('post-id', 'comment-id', profileId: 'profile-id');
echo $result->accepted; // true

// Hide / unhide a comment
$client->comments()->hide('post-id', 'comment-id', profileId: 'profile-id');
$client->comments()->unhide('post-id', 'comment-id', profileId: 'profile-id');

// Like / unlike a comment
$client->comments()->like('post-id', 'comment-id', profileId: 'profile-id');
$client->comments()->unlike('post-id', 'comment-id', profileId: 'profile-id');

// Edit a comment you published (Facebook, YouTube). Runs in the background —
// the outcome arrives as comment.edited / comment.edit_failed.
$client->comments()->edit('post-id', 'comment-id', profileId: 'profile-id', text: 'Fixed a typo');

// Pin / unpin a comment (TikTok)
$client->comments()->pin('post-id', 'comment-id', profileId: 'profile-id');
$client->comments()->unpin('post-id', 'comment-id', profileId: 'profile-id');

// Privately reply to a comment via DM (Instagram/Facebook). One per comment,
// within 7 days of it. Returns a Message, not a Comment.
$message = $client->comments()->privateReply('post-id', 'comment-id', profileId: 'profile-id', text: 'DM-ing you the details!');
echo "Reply queued as message {$message->id} in chat {$message->chatId}\n";

// Private replies take quickReplies, buttons, card and elements, as in
// messages()->send(); text may be empty when sending elements.
$client->comments()->privateReply('post-id', 'comment-id', profileId: 'profile-id', text: '',
    elements: [['title' => 'Red sneaker', 'image_url' => 'https://cdn.example.com/red.png',
        'buttons' => [['type' => 'web_url', 'title' => 'Buy', 'url' => 'https://shop.example.com/red']]]],
);

// Filter by when PostProxy received the comment (created_at, not posted_at).
// A bare date means that date's start of day. Applies to top-level comments —
// one in range brings its full replies array with it.
$recent = $client->comments()->list(
    'post-id',
    profileId: 'profile-id',
    from: '2026-03-25',
    to: '2026-03-26T12:00:00Z',
);

Comments across posts

comments()->listAll() returns comments spanning every post in the profile group in one request — the comments counterpart to posts()->stats(). Every filter is optional.

This list is flat. Unlike the per-post list, replies are not nested: every comment, top-level or reply, is its own entry linked to its parent by parentExternalId, so total counts every comment and paging is exact. Entries are BulkComment, which adds postId, profileId, and platform.

$all = $client->comments()->listAll(
    profiles: ['instagram', 'prof-abc'],  // profile IDs or network names, mixed
    postIds: ['post-1', 'post-2'],        // omit for every post in scope
    placementId: 'page-id',               // optional: one page / organization
    from: '2026-03-25',
    perPage: 50,                          // max 100
);

foreach ($all->data as $c) {
    // Each entry says where it came from, so you can act on it with the
    // post-scoped methods above.
    echo "{$c->platform} {$c->postId} {$c->profileId}: {$c->body}\n";

    if ($c->parentExternalId !== null) {
        echo "  ↳ reply to {$c->parentExternalId}\n";
    }
}

// Reply to one of them
$first = $all->data[0];
$client->comments()->create($first->postId, $first->profileId, 'Thanks!', parentId: $first->id);

Unknown or out-of-scope IDs in postIds and profiles are ignored rather than erroring. Results are ordered newest first by receipt time.

Direct Messages

Manage conversations (Facebook, Instagram, Telegram, Bluesky, WhatsApp) through two resources: chats() for conversations and messages() for the messages within them.

// List chats for a DM-capable profile (paginated)
$chats = $client->chats()->list('profile-id', perPage: 20);
foreach ($chats->data as $chat) {
    $who = $chat->participantUsername ?? $chat->participantExternalId;
    echo "{$who}: last message at " . ($chat->lastMessageAt?->format('c') ?? 'never') . "\n";
}

// Find or create a chat with a participant
$chat = $client->chats()->create('profile-id', 'participant-external-id', participantUsername: 'jane_doe');

// Get a single chat
$chat = $client->chats()->get('chat-id');

// Archive / unarchive a chat (Bluesky only)
$client->chats()->archive('chat-id');
$client->chats()->unarchive('chat-id');

// Read receipt and typing indicator (typing: Facebook, Instagram, WhatsApp)
$client->chats()->markRead('chat-id');
$client->chats()->startTyping('chat-id');
$client->chats()->stopTyping('chat-id');

// Thread control (Facebook & Instagram): move the conversation between Meta apps
// sharing the inbox. Each returns the Chat; $chat->metadata['thread_control'] holds the owner.
$client->chats()->passThreadControl('chat-id', '263902037430900', metadata: 'escalated to support');
$client->chats()->takeThreadControl('chat-id');
$client->chats()->releaseThreadControl('chat-id');
$client->chats()->requestThreadControl('chat-id'); // the owner decides; watch chat.thread_control_changed
$owner = $client->chats()->threadOwner('chat-id'); // ThreadOwner: appId, expiresAt (null when idle)

// List messages in a chat (filter by direction/status)
$messages = $client->messages()->list('chat-id', direction: 'inbound');
foreach ($messages->data as $msg) {
    echo "[{$msg->direction}] {$msg->body}\n";
    foreach ($msg->attachments as $att) {
        echo "  attachment: {$att->type} -> {$att->url}\n";
    }
    foreach ($msg->reactions as $reaction) {
        echo "  reaction: {$reaction->emoji}\n";
    }
}

// Send a text message (within the platform's messaging window)
$sent = $client->messages()->send('chat-id', body: 'Yes, we ship worldwide!');

// Send with a messaging tag (Facebook/Instagram), by hosted URL, or from a local file
$client->messages()->send('chat-id', body: 'Following up.', tag: 'HUMAN_AGENT');
$client->messages()->send('chat-id', media: ['https://cdn.example.com/photo.png']);
$client->messages()->send('chat-id', mediaFiles: ['./photo.png']);

// Get a single message
$message = $client->messages()->get('message-id');

// Edit an outbound message (Telegram only)
$client->messages()->edit('message-id', body: 'Updated answer.');

// React / unreact (Facebook & Instagram)
$client->messages()->react('message-id', reaction: 'love', emoji: '❤️');
$client->messages()->unreact('message-id');

// Telegram: thread under a message and attach an inline keyboard.
// replyToExternalId (quote a message) also works on WhatsApp and Instagram.
$client->messages()->send('chat-id',
    body: 'Pick one',
    replyToExternalId: '4821',
    replyMarkup: ['inline_keyboard' => [[['text' => 'Track order', 'callback_data' => 'track:1']]]],
);

Quick replies and buttons (Facebook & Instagram)

Meta's two interactive primitives. Quick replies are chips above the participant's composer that disappear once tapped; buttons are attached to the message and stay in the thread. Telegram's equivalent is replyMarkup above — passing quickReplies or buttons on a Telegram or Bluesky chat returns 422.

Each param accepts model instances or plain arrays, whichever you prefer:

use PostProxy\Types\CardDefaultAction;
use PostProxy\Types\MessageButton;
use PostProxy\Types\MessageCard;
use PostProxy\Types\QuickReply;

// Quick replies — up to 13. title ≤ 20 chars, payload ≤ 1000.
$client->messages()->send('chat-id',
    body: 'What can I help with?',
    quickReplies: [
        QuickReply::make('Track order', 'TRACK'),
        ['title' => 'Talk to support', 'payload' => 'HELP'],
    ],
);

// Buttons — up to 3, each either web_url or postback. card is optional and
// requires buttons.
$client->messages()->send('chat-id',
    body: 'Your order shipped',
    buttons: [
        MessageButton::webUrl('Track', 'https://shop.example.com/o/123'),
        MessageButton::postback('Cancel', 'CANCEL:123'),
    ],
    card: new MessageCard([
        'subtitle' => 'Arriving Friday',
        'image_url' => 'https://cdn.example.com/shoe.png',
        'default_action' => CardDefaultAction::webUrl('https://shop.example.com/o/123'),
    ]),
);

Buttons are delivered as a Meta generic template and your body becomes the template's element title — so body is capped at 80 characters when buttons are present. That is Meta's limit, not Postproxy's, and a longer body is rejected with a 422 naming the length. Buttons cannot be combined with media. Instagram is stricter than Messenger: it delivers quick replies only on a plain-text message, so quickReplies with media or with buttons returns 422 on Instagram while both are accepted on Facebook.

Validation happens server-side and names the offending index — buttons[1].url must be an https:// URL — surfacing as the SDK's usual exception for a 422.

The new params are sent on the JSON path only. To combine quick replies with an attachment, pass media as a hosted URL rather than uploading via mediaFiles.

Carousels (Facebook & Instagram)

elements sends a horizontally scrolling carousel of up to 10 cards. Each card needs a title (≤ 80 characters) plus at least one other field, and takes up to 3 buttons. It can't be combined with body, buttons or card.

use PostProxy\Types\MessageElement;

$client->messages()->send('chat-id', elements: [
    new MessageElement([
        'title' => 'Red sneaker',
        'subtitle' => '$89 — sizes 38-46',
        'image_url' => 'https://cdn.example.com/red.png',
        'buttons' => [MessageButton::webUrl('Buy', 'https://shop.example.com/red')],
    ]),
    ['title' => 'Blue sneaker', 'buttons' => [['type' => 'postback', 'title' => 'Notify me', 'payload' => 'NOTIFY:blue']]],
]);

Inbound and outbound messages expose elements (null when absent) and storyReply.

A tap comes back as an inbound message carrying tappedAction:

$inbound = $client->messages()->list('chat-id', direction: 'inbound');
foreach ($inbound->data as $msg) {
    if ($msg->tappedAction !== null) {
        // kind: quick_reply | postback | callback_query
        echo "{$msg->tappedAction->kind}: {$msg->tappedAction->payload}\n";
    }
}

Subscribe to message.received to react to taps as they happen — the same field is on the webhook payload. tappedAction is derived rather than stored, so it also resolves for taps recorded before Postproxy exposed it, including Instagram ice-breaker taps and Telegram callback queries (TappedAction::KIND_CALLBACK_QUERY). A tap also opens the 24h window.

WhatsApp

WhatsApp chats run through a phone number — a placement of the WhatsApp profile — so chats()->create() takes placementId. Send exactly one of body, media, template, interactive, location or contacts. Outside the 24-hour customer service window ($chat->withinMessagingWindow === false) only a template gets through.

use PostProxy\Types\MessageLocation;
use PostProxy\Types\MessageTemplateInput;

$numbers = $client->profiles()->placements('whatsapp-profile-id');
$chat = $client->chats()->create('whatsapp-profile-id', '15551234567', placementId: $numbers->data[0]->id);

// A template, by name (+ language) or id. variables are positional:
// header, then body, then dynamic URL buttons.
$client->messages()->send($chat->id, template: new MessageTemplateInput([
    'name' => 'order_shipped',
    'language' => 'en_US',
    'variables' => ['Jane', '#1234'],
    'button_params' => [['index' => 0, 'sub_type' => 'url', 'text' => '1234']],
]));

// Inside the window
$client->messages()->send($chat->id, location: new MessageLocation(['latitude' => 52.52, 'longitude' => 13.405, 'name' => 'Pickup point']));
$client->messages()->send($chat->id, contacts: [['name' => ['formatted_name' => 'Jane Doe'], 'phones' => [['phone' => '+15551234567']]]]);
$client->messages()->send($chat->id, interactive: ['type' => 'button', 'body' => ['text' => 'Pick one'], 'action' => ['buttons' => [/* ... */]]]);
$client->messages()->send($chat->id, body: 'Your code is 1234', category: 'utility', linkPreview: false);
$client->messages()->send($chat->id, media: ['https://files.example.com/invoice.pdf'], filename: 'invoice.pdf');
$client->messages()->send($chat->id, media: ['https://files.example.com/note.ogg'], voiceNote: true);

Chat gains externalPlacementId, group and withinMessagingWindow; Message gains template (a MessageTemplateRef), interactive, location, contacts and errorDetails.

WhatsApp templates

Message templates live on the WhatsApp Business account (the profile). Address one by its PostProxy id, Meta's id, or its name — pass language with a name that exists in several.

// Create — Meta reviews it before it can be sent
$template = $client->whatsappTemplates()->create('whatsapp-profile-id', [
    'name' => 'order_shipped',
    'category' => 'UTILITY',
    'language' => 'en_US',
    'components' => [['type' => 'BODY', 'text' => 'Hi {{1}}, your order {{2}} has shipped.']],
]);
echo "{$template->name} {$template->status}\n"; // "order_shipped PENDING"

// List (refresh: true re-reads from Meta first), get, update, delete
$approved = $client->whatsappTemplates()->list('whatsapp-profile-id', status: 'APPROVED', refresh: true);
$one = $client->whatsappTemplates()->get('whatsapp-profile-id', 'order_shipped', language: 'en_US');
$client->whatsappTemplates()->update('whatsapp-profile-id', $template->id, ['message_send_ttl_seconds' => 3600]);
$client->whatsappTemplates()->delete('whatsapp-profile-id', 'order_shipped'); // every language

// Meta's ready-made library
$library = $client->whatsappTemplates()->library('whatsapp-profile-id', name: 'delivery');

Follow review via get() or the message_template.status_updated webhook.

WhatsApp number administration

whatsapp() manages the phone number itself. $phoneNumberId is the placement id from profiles()->placements(); dataset and conversion calls are account-level and don't take one. Responses are Meta's payloads, returned as arrays.

$wa = $client->whatsapp();

$wa->numberInfo('profile-id', 'phone-number-id');
$wa->registerPhoneNumber('profile-id', 'phone-number-id', '123456');           // 2-step PIN
$wa->requestVerificationCode('profile-id', 'phone-number-id', method: 'SMS');
$wa->verifyPhoneNumber('profile-id', 'phone-number-id', '000111');

$wa->businessProfile('profile-id', 'phone-number-id');
$wa->updateBusinessProfile('profile-id', 'phone-number-id', about: 'Fresh coffee', websites: ['https://example.com']);
$wa->updateBusinessProfilePhoto('profile-id', 'phone-number-id', url: 'https://example.com/logo.png');

$wa->displayName('profile-id', 'phone-number-id');
$wa->requestDisplayNameChange('profile-id', 'phone-number-id', 'Acme Coffee'); // → placement.name_status_updated

$wa->username('profile-id', 'phone-number-id');
$wa->usernameSuggestions('profile-id', 'phone-number-id');
$wa->setUsername('profile-id', 'phone-number-id', 'acmecoffee', transferAction: 'force_transfer');
$wa->deleteUsername('profile-id', 'phone-number-id');

$wa->blockedUsers('profile-id', 'phone-number-id', limit: 50);
$wa->blockedUserStatus('profile-id', 'phone-number-id', '15557654321');
$wa->blockUsers('profile-id', 'phone-number-id', ['15557654321']);
$wa->unblockUsers('profile-id', 'phone-number-id', ['15557654321']);

// Groups
$group = $wa->createGroup('profile-id', 'phone-number-id', 'VIP customers', joinApprovalMode: 'approval_required');
$groupId = (string) $group['id'];
$wa->groups('profile-id', 'phone-number-id');
$wa->group('profile-id', 'phone-number-id', $groupId);
$wa->updateGroup('profile-id', 'phone-number-id', $groupId, subject: 'VIPs');
$wa->addGroupParticipants('profile-id', 'phone-number-id', $groupId, ['15551234567']);
$wa->removeGroupParticipants('profile-id', 'phone-number-id', $groupId, ['15551234567']);
$wa->createGroupInviteLink('profile-id', 'phone-number-id', $groupId);
$wa->groupJoinRequests('profile-id', 'phone-number-id', $groupId);
$wa->approveGroupJoinRequests('profile-id', 'phone-number-id', $groupId, ['15557654321']);
$wa->rejectGroupJoinRequests('profile-id', 'phone-number-id', $groupId, ['15550000000']);
$wa->deleteGroup('profile-id', 'phone-number-id', $groupId);

// Conversions for click-to-WhatsApp ads
$wa->dataset('profile-id');
$wa->createDataset('profile-id');
$wa->sendConversionEvent('profile-id', eventName: 'Purchase', eventId: 'order-1234',
    phone: '15551234567', currency: 'USD', value: 24.5);

// Download inbound media (e.g. an attachment's externalId) as raw bytes
$file = $wa->media('profile-id', 'phone-number-id', 'media-id');
file_put_contents('download', $file->data); // $file->contentType, e.g. "image/jpeg"

Profile comments (Google Business reviews)

Profile-level comments expose Google Business reviews and replies. Reviews are user-generated — the SDK lets you list/get them and reply to or delete your own replies. Reviews sync twice daily.

// List reviews for a profile (paginated)
$reviews = $client->profileComments()->list('profile-id');
foreach ($reviews->data as $review) {
    echo "{$review->authorUsername}: {$review->body}\n";
    foreach ($review->replies as $reply) {
        echo "  reply: {$reply->body}\n";
    }
}

// Filter by placement (location)
$reviews = $client->profileComments()->list('profile-id', placementId: 'accounts/123/locations/456');

// Get a single review
$review = $client->profileComments()->get('profile-id', 'review-id');

// Reply to a review (parentId is the review id)
$reply = $client->profileComments()->create('profile-id', parentId: 'review-id', text: 'Thanks for visiting!');

// Delete your reply
$client->profileComments()->delete('profile-id', 'reply-id');

Profiles

// List profiles
$result = $client->profiles()->list();

// Get a single profile
$profile = $client->profiles()->get('prof-id');

// Get placements for a profile
$placements = $client->profiles()->placements('prof-id');

// Move a placement (e.g. a Facebook Page or Telegram channel) to another group
$placement = $client->profiles()->assignPlacementToGroup(
    'prof-id',
    'placement-external-id',
    'pg-other',
);
echo $placement->profileGroupId; // "pg-other"

// Ice breakers (Instagram DMs): FAQ prompts shown when a user opens a chat
$result = $client->profiles()->iceBreakers('prof-id');
foreach ($result->iceBreakers as $ib) {
    echo "{$ib->question}\n";
}

$client->profiles()->setIceBreakers('prof-id', [
    ['question' => 'What services do you offer?', 'payload' => 'services'],
    ['question' => 'What are your hours?', 'payload' => 'hours'],
]); // 1-4 items

$client->profiles()->deleteIceBreakers('prof-id');

// Move the whole profile (with its placements) to another group
$profile = $client->profiles()->assignToGroup('prof-id', 'pg-other');

// Profile fields
echo "{$profile->username} {$profile->platformId} {$profile->avatarUrl} {$profile->platformUrl}\n";
echo $profile->inactiveReason; // "network_taken" when the account was connected elsewhere

// Delete a profile
$result = $client->profiles()->delete('prof-id');

// Daily stats series. attribution: "publish" attributes post metrics to the day
// the post was published instead of the day they were received.
$daily = $client->profiles()->dailyStats('prof-id', from: '2026-09-01', to: '2026-09-30', attribution: 'publish');
foreach ($daily->data->records as $day) {
    echo "{$day->date}: " . json_encode($day->metrics) . "\n"; // e.g. {"impressions":120}
}

// Followers gained / lost per day
$history = $client->profiles()->followerHistory('prof-id', from: '2026-09-01');
foreach ($history->data->records as $day) {
    echo "{$day->date}: {$day->followersTotal} (+{$day->followersGained} / -{$day->followersLost})\n";
}

// Instagram: is a DM participant verified / following you?
$status = $client->profiles()->participantStatus('prof-id', 'igsid');
var_dump($status->isVerifiedUser, $status->isUserFollowBusiness);

// LinkedIn: resolve a company page to the URN used to @-mention it
$mention = $client->profiles()->resolveMention('prof-id', 'https://www.linkedin.com/company/acme');
echo $mention->urn; // "urn:li:organization:1234"

// Instagram: music for reels (profiles connected via Facebook login), used as
// InstagramParams 'audio_id'
$audio = $client->profiles()->instagramAvailableAudio('prof-id', audioType: 'music', searchQuery: 'summer');

// Instagram (Facebook login): incoming collaboration invites
$invites = $client->profiles()->instagramCollaborationInvites('prof-id'); // pass after: $invites->nextCursor for more
$client->profiles()->acceptInstagramCollaborationInvite('prof-id', $invites->collaborationInvites[0]->mediaId);
$client->profiles()->declineInstagramCollaborationInvite('prof-id', '17890000000000001');

// Pinterest boards: a board is a Placement; its id is the board_id you publish to
$board = $client->profiles()->createPinterestBoard('pin-profile-id', 'Summer Recipes',
    description: 'Light dinners for hot days',
    privacy: 'PUBLIC', // or 'PROTECTED'
);
$client->profiles()->deletePinterestBoard('pin-profile-id', $board->id); // also deletes its Pins

// YouTube playlists: publish into one with YouTubeParams 'playlist_id'
$playlists = $client->profiles()->youtubePlaylists('yt-profile-id'); // ->data, ->nextPageToken
$playlist = $client->profiles()->createYouTubePlaylist('yt-profile-id', 'Tutorials',
    description: 'Step-by-step guides',
    privacyStatus: 'public',
);
$client->profiles()->updateYouTubePlaylist('yt-profile-id', $playlist->id, privacyStatus: 'unlisted');
$client->profiles()->deleteYouTubePlaylist('yt-profile-id', $playlist->id); // videos are kept

// Facebook Page about / contact fields. pageId is the placement id.
$page = $client->profiles()->facebookPage('prof-id', 'page-id', fields: 'about,website');
$client->profiles()->updateFacebookPage('prof-id', 'page-id', ['about' => 'Fresh coffee since 2010']);

// Profile stats timeseries — placementId required for facebook, linkedin, telegram
$stats = $client->profiles()->getProfileStats(
    'prof_li_001',
    placementId: '108520199',
    from: '2026-04-01T00:00:00Z',
);
foreach ($stats->data->records as $r) {
    echo $r->recordedAt . ': ' . $r->stats['followerCount'] . "\n";
}

// Bluesky — no placements
$bsky = $client->profiles()->getProfileStats('prof_bsky_001');
echo end($bsky->data->records)->stats['followersCount'];

Every stats record (post stats and profile stats alike) carries rawStats alongside the normalized stats, exposing each metric under its original platform name:

$stats = $client->posts()->stats(['post-id']);
$record = $stats->data['post-id']->platforms[0]->records[0];

echo $record->stats['impressions'];          // normalized
echo $record->rawStats['views'];             // Instagram's own name
echo $record->rawStats['impression_count'];  // Twitter/X's own name

LinkedIn post stats now normalize likes, comments, shares, and clicks alongside impressions — previously only impressions was normalized.

Post syncs & backfill

Postproxy mirrors posts published natively on a platform into your account. Every one of those pulls is recorded as a post sync: the one fired when the profile connects, the recurring poll, and any backfill you start.

// Start a backfill — walks the feed backwards from the newest post in batches
// of 25 until it reaches `from` or the platform stops returning posts.
$sync = $client->profiles()->backfillPosts('prof-id', '2025-01-01');
echo "{$sync->id} {$sync->status}"; // "sync456def pending"

// Poll it to completion — finished when status is "completed" or "failed"
$run = $client->profiles()->postSync('prof-id', $sync->id);
echo "{$run->postsImported} of {$run->postsSeen}";

// List recent runs (kept for 30 days), newest first
$runs = $client->profiles()->postSyncs(
    'prof-id',
    trigger: 'backfill',   // connect | scheduled | backfill
    status: 'completed',   // pending | running | completed | failed
    perPage: 25,
);
PostSync property Description
id Sync identifier
profileId Profile this run belongs to
placementId Placement the run pulled from, or null
kind Always posts today
trigger connect, scheduled, or backfill
status pending, running, completed, or failed
startedAt / completedAt DateTimeImmutable or null
postsSeen Posts the platform returned across the run
postsImported Posts that were new and got created
backfillFrom The date floor requested; null for connect/scheduled
oldestPostedAt Publish date of the oldest post the run reached
error Platform error message when status is failed
createdAt DateTimeImmutable

How far back a backfill reaches depends on the platform's API, not on Postproxy: where history is pageable we follow it, otherwise the run ends early with whatever it got and still reports status === 'completed'.

Only one backfill runs per profile at a time — starting a second throws ConflictException carrying the running one's id:

use PostProxy\Exceptions\ConflictException;

try {
    $client->profiles()->backfillPosts('prof-id', '2025-01-01');
} catch (ConflictException $e) {
    $runningId = $e->response['profile_sync_id'];
    // Poll the run that's already going.
}

Chat backfill works the same way for DM history, recorded as ChatSync runs (chatsSeen, messagesImported, oldestUpdatedAt instead of the post counters):

$sync = $client->profiles()->backfillChats('prof-id', '2026-01-01T00:00:00Z');
$run = $client->profiles()->chatSync('prof-id', $sync->id);
$runs = $client->profiles()->chatSyncs('prof-id', status: 'running');

Posts you already have are skipped, so overlapping backfills are safe. Imported posts behave exactly like ones the poll picks up (source: "imported", post.imported webhook), but a backfill's follow-up work is queued at a lower priority so a deep run can't slow down publishing.

Profile Groups

// List profile groups
$result = $client->profileGroups()->list();

// Get a single profile group
$group = $client->profileGroups()->get('pg-id');

// Create a profile group
$group = $client->profileGroups()->create('My Group');

// Delete a profile group
$result = $client->profileGroups()->delete('pg-id');

// Initialize an OAuth connection
$connection = $client->profileGroups()->initializeConnection(
    'pg-id',
    platform: 'instagram',
    redirectUrl: 'https://myapp.com/callback',
);
echo $connection->url; // Redirect user here

// onboarding picks the flow: Instagram "instagram" (default) or "facebook"
// (Facebook login — needed for reel music); WhatsApp "api" (Cloud API) or
// "business_app" (WhatsApp Business app). refreshConnection re-authorizes an
// existing profile.
$connection = $client->profileGroups()->initializeConnection(
    'pg-id',
    platform: 'whatsapp',
    redirectUrl: 'https://myapp.com/callback',
    onboarding: 'business_app',
);

// BlueSky — app password (synchronous)
$bsky = $client->profileGroups()->connectBluesky(
    'pg-id',
    identifier: 'yourname.bsky.social',
    appPassword: 'xxxx-xxxx-xxxx-xxxx',
);
echo $bsky->profile->id;

// Telegram — bring-your-own-bot. Channels populate asynchronously; poll
// placements until non-empty.
$tg = $client->profileGroups()->connectTelegram(
    'pg-id',
    botToken: '123456789:ABCdef-GhIJklMnOpQrStUvWxYz',
);
echo $tg->nextStep;

$placements = [];
while (empty($placements)) {
    $placements = $client->profiles()->placements($tg->profile->id)->data;
    if (empty($placements)) sleep(3);
}

Placements

Account-wide API keys only — placements span profile groups. A placement (a Facebook Page, LinkedIn organization, WhatsApp number, ...) can be reachable through several connected profiles; one of them is active.

$placements = $client->placements()->list(perPage: 50);
foreach ($placements->data as $p) {
    echo "{$p->platform} {$p->name}: active via {$p->activeProfile->name}\n";
}

// Choose which connected profile PostProxy posts and reads through
$client->placements()->setActiveProfile('placement-id', 'profile-id');

API keys

Manage profile-group-scoped keys. Requires an account-wide key.

$keys = $client->apiKeys()->list();
$key = $client->apiKeys()->create('CI', 'pg-id');
echo $key->token; // shown only once — store it
$client->apiKeys()->delete($key->id);

Google Business locations

googleBusiness() manages a location's own data — hours, attributes, services, menus, action links and photos — separately from posting to it. $locationId is the placement id from profiles()->placements(). Google's payloads are camelCased and passed through untouched as arrays, so you can PATCH back exactly what you GET; updates take a $fields list naming which top-level fields to change.

$gbp = $client->googleBusiness();

$location = $gbp->location('profile-id', 'location-id', readMask: 'title,phoneNumbers,websiteUri');
$gbp->updateLocation('profile-id', 'location-id', ['websiteUri'], ['websiteUri' => 'https://example.com']);
$gbp->availableLocationCategories('profile-id', 'location-id', 'US', languageCode: 'en');

$gbp->updateHours('profile-id', 'location-id', fields: ['regularHours'], regularHours: [
    'periods' => [['openDay' => 'MONDAY', 'openTime' => ['hours' => 9], 'closeDay' => 'MONDAY', 'closeTime' => ['hours' => 17]]],
]);

$gbp->attributes('profile-id', 'location-id');
$gbp->availableAttributes('profile-id', 'location-id', showAll: true);
$gbp->updateAttributes('profile-id', 'location-id', [['name' => 'attributes/has_wifi', 'values' => [true]]], attributeMask: ['attributes/has_wifi']);

$gbp->serviceList('profile-id', 'location-id');
$gbp->updateServiceList('profile-id', 'location-id', $serviceItems);
$gbp->foodMenus('profile-id', 'location-id');
$gbp->updateFoodMenus('profile-id', 'location-id', $menus, fields: ['menus']);

// Booking / ordering links
$gbp->availablePlaceActionTypes('profile-id', 'location-id');
$gbp->createPlaceActionLink('profile-id', 'location-id', 'https://book.example.com', 'APPOINTMENT', isPreferred: true);
$links = $gbp->placeActionLinks('profile-id', 'location-id');
$gbp->updatePlaceActionLink('profile-id', 'location-id', 'locations/1/placeActionLinks/2', ['uri'], uri: 'https://book2.example.com');
$gbp->deletePlaceActionLink('profile-id', 'location-id', 'locations/1/placeActionLinks/2');

// Photos
$gbp->createMedia('profile-id', 'location-id', 'https://example.com/storefront.jpg', category: 'EXTERIOR', mediaFormat: 'PHOTO');
$media = $gbp->media('profile-id', 'location-id');
$gbp->deleteMedia('profile-id', 'location-id', 'accounts/1/locations/1/media/9');

// Search keywords that showed the location, month by month (max 12 months).
// Unlike the rest of googleBusiness(), this one is typed: SearchKeywordsResponse.
$result = $gbp->searchKeywords('profile-id', 'location-id', fromMonth: '2026-07', toMonth: '2026-09');
foreach ($result->months as $month) {
    foreach ($month->keywords as $k) {
        // Low-volume keywords have impressions null and an impressionsThreshold bound
        echo "{$month->month} {$k->keyword}: " . ($k->impressions ?? "<{$k->impressionsThreshold}") . "\n";
    }
}

Platform Parameters

use PostProxy\Types\PlatformParams\PlatformParams;
use PostProxy\Types\PlatformParams\FacebookParams;
use PostProxy\Types\PlatformParams\InstagramParams;
use PostProxy\Types\PlatformParams\TelegramParams;
use PostProxy\Types\PlatformParams\BlueskyParams;

$platforms = new PlatformParams([
    'facebook' => new FacebookParams(['format' => 'post', 'first_comment' => 'Hi!']),
    'instagram' => new InstagramParams(['format' => 'reel']),
    'bluesky' => new BlueskyParams(['format' => 'post']),
    'telegram' => new TelegramParams([
        'chat_id' => '-1001234567890',
        'parse_mode' => 'HTML',
        'disable_link_preview' => true,
    ]),
]);

$post = $client->posts()->create('Hello!', profiles: ['prof-1'], platforms: $platforms);

Instagram user tags

Tag public Instagram accounts in a post — feed post, reel, or story:

use PostProxy\Types\PlatformParams\InstagramUserTag;

$platforms = new PlatformParams([
    'instagram' => new InstagramParams([
        'format' => 'post',
        'user_tags' => [
            new InstagramUserTag('natgeo', x: 0.5, y: 0.4),               // slide 0
            new InstagramUserTag('nasa', x: 0.2, y: 0.8, mediaIndex: 1),  // slide 1
            new InstagramUserTag('spacex', mediaIndex: 2),                // video — username only
        ],
    ]),
]);

$client->posts()->create(
    'Shot on location',
    profiles: ['ig-profile-id'],
    media: ['https://example.com/1.jpg', 'https://example.com/2.jpg', 'https://example.com/3.mp4'],
    platforms: $platforms,
);
  • Images require x and y — floats 0.0–1.0 measured from the top-left corner.
  • Reels and video slides are tagged by username only; coordinates are ignored and dropped.
  • Stories accept coordinates but don't need them.
  • mediaIndex picks the carousel slide (0-based, defaults to 0, video slides included).
  • A leading @ on a username is stripped for you.

Coordinates outside 0.0–1.0, a mediaIndex past the last media item, or an image tag missing x/y are rejected with a ValidationException naming the offending entry. Accounts that are private or have tagging turned off are silently skipped by Instagram at publish time.

Alt text, AI labels, covers and more

use PostProxy\Types\PlatformParams\LinkedInParams;
use PostProxy\Types\PlatformParams\ThreadsParams;
use PostProxy\Types\PlatformParams\TikTokParams;
use PostProxy\Types\PlatformParams\YouTubeParams;

$platforms = new PlatformParams([
    // alt_text: one string, or one per media item in order. Facebook, Instagram,
    // LinkedIn, Bluesky, Threads, Pinterest and Twitter.
    'instagram' => new InstagramParams([
        'format' => 'reel',
        'alt_text' => ['First slide', 'Second slide'],
        'made_with_ai' => true,                    // Instagram and Facebook
        'audio_id' => '1234567890',                // from profiles()->instagramAvailableAudio()
        'audio_volume' => 40,                      // 1-100
        'video_volume' => 80,
        'strip_audio' => false,
        'trial_strategy' => 'MANUAL',              // trial reel: MANUAL or SS_PERFORMANCE
        'disable_comment' => true,                 // post, reel
        'location_id' => '110843418940484',        // Facebook Page with an address; post, reel
        'share_to_feed' => false,                  // reels: Reels tab only, not the grid
    ]),
    // Link post (text only); countries limits who sees it
    'facebook' => new FacebookParams(['link' => 'https://example.com', 'countries' => ['US', 'CA']]),
    // Poll; reshare_urn reshares a post; countries for organization pages
    'linkedin' => new LinkedInParams([
        'format' => 'poll',
        'poll_question' => 'Tabs or spaces?',
        'poll_options' => ['Tabs', 'Spaces'],
        'poll_duration_days' => 3,                 // 1, 3, 7 (default) or 14
    ]),
    // On a non-poll post: document_title (shown above an attached document) and
    // first_comment (posted right after, as the same author; 1,250 characters or fewer)
    // 'linkedin' => new LinkedInParams(['document_title' => 'Q3 report', 'first_comment' => 'Full deck: ...']),
    // Add the video to a playlist from profiles()->youtubePlaylists()
    'youtube' => new YouTubeParams(['title' => 'Tutorial', 'playlist_id' => 'PL1234567890']),
    'threads' => new ThreadsParams(['topic_tag' => 'Coffee']),
    // upload_as_draft sends to the creator's TikTok inbox; thumb_offset picks a frame (ms)
    'tiktok' => new TikTokParams(['format' => 'video', 'upload_as_draft' => true, 'cover_url' => 'https://example.com/cover.jpg']),
]);

cover_file (Instagram, TikTok, YouTube, Pinterest) takes a local path to a cover image, uploaded with the post — the request switches to multipart even without mediaFiles. A base64 data URL (data:image/jpeg;base64,...) is sent as-is instead. Works on create() and update().

$client->posts()->create('Behind the scenes', profiles: ['tiktok-profile-id'],
    media: ['https://example.com/bts.mp4'],
    platforms: new PlatformParams([
        'tiktok' => new TikTokParams(['format' => 'video', 'cover_file' => './cover.jpg']),
    ]),
);

Supported platforms: facebook, instagram, tiktok, linkedin, youtube, twitter, threads, pinterest, bluesky, telegram, google_business, whatsapp (DMs only). Telegram requires a chat_id per post — list channels with $client->profiles()->placements($profileId).

Twitter supports polls: pass new TwitterParams(['format' => 'poll', 'poll_options' => ['Yes', 'No'], 'poll_duration_minutes' => 1440]) — 2-4 options (max 25 chars each), duration 5 to 10080 minutes.

Google Business

Google Business posts use the googleBusiness property on PlatformParams (a plain associative array). The location_id is the location resource path returned by $client->profiles()->placements(). Supported formats: standard, event, offer. CTA actions: LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL. Media is limited to one image (≤5 MB).

use PostProxy\Types\PlatformParams\PlatformParams;

$platforms = new PlatformParams([
    'google_business' => [
        'format' => 'standard',
        'location_id' => 'accounts/123/locations/456',
        'cta_action_type' => 'LEARN_MORE',
        'cta_url' => 'https://example.com',
    ],
]);

Error Handling

use PostProxy\Exceptions\AuthenticationException;
use PostProxy\Exceptions\NotFoundException;
use PostProxy\Exceptions\ConflictException;
use PostProxy\Exceptions\ForbiddenException;
use PostProxy\Exceptions\RateLimitException;
use PostProxy\Exceptions\ValidationException;
use PostProxy\Exceptions\BadRequestException;
use PostProxy\Exceptions\PostProxyException;

try {
    $client->posts()->get('bad-id');
} catch (AuthenticationException $e) {
    // 401
} catch (ForbiddenException $e) {
    // 403 — account canceled or past due, or the platform refused the action
} catch (RateLimitException $e) {
    // 429 — retry after $e->retryAfter seconds (null when the header is absent)
    sleep($e->retryAfter ?? 60);
} catch (NotFoundException $e) {
    // 404
} catch (ConflictException $e) {
    // 409 — duplicate submission, a backfill already running, or an in-flight
    // Idempotency-Key. Details are in $e->response.
} catch (ValidationException $e) {
    // 422
} catch (BadRequestException $e) {
    // 400
} catch (PostProxyException $e) {
    // Other errors
    echo $e->getMessage();
    echo $e->statusCode;
    echo print_r($e->response, true);
}
Status Exception Thrown for
400 BadRequestException Missing required parameters
401 AuthenticationException Invalid, missing, or insufficient API key permissions
403 ForbiddenException Account canceled or past due, or the platform refused the action
404 NotFoundException Resource does not exist or is not accessible
409 ConflictException Duplicate submission (duplicate_post_id), a backfill already running (profile_sync_id), or an in-flight Idempotency-Key
422 ValidationException Validation failed
429 RateLimitException Rate limit reached; $e->retryAfter holds the Retry-After seconds

Development

composer install
./vendor/bin/phpunit

License

MIT