Social Media API for PHP: Complete Integration Guide
Post to Instagram, TikTok, YouTube, LinkedIn & 10 more platforms from PHP. Upload media, handle webhooks, verify signatures, pull analytics.
TL;DR
- No SDK needed. Native PHP cURL or any HTTP client (Guzzle, Laravel Http) works.
- One API key, one base URL, 14+ platforms. Upload media, create posts, pull analytics, handle webhooks.
- Every code example in this guide is verified against the actual API contracts. Copy-paste with confidence.
What You'll Need
- PHP 7.4+ (8.0+ recommended for typed properties)
- cURL extension enabled (
php -m | grep curl) - bundle.social API key - get one from the dashboard
No composer packages required.
We'll use PHP's native cURL. If you prefer Guzzle or Laravel's Http client, swap in the HTTP calls - the endpoints and payloads stay the same.
Setup: The Client Class
Here's a minimal client that handles auth, JSON encoding, multipart uploads, and proper error surfacing:
<?php class BundleSocialClient { private string $apiKey; private string $baseUrl = 'https://api.bundle.social/api/v1'; public function __construct(string $apiKey) { $this->apiKey = $apiKey; } public function get(string $endpoint, array $params = []): array { $query = !empty($params) ? '?' . http_build_query($params) : ''; return $this->request('GET', $endpoint . $query); } public function post(string $endpoint, array $data): array { return $this->request('POST', $endpoint, $data); } public function patch(string $endpoint, array $data): array { return $this->request('PATCH', $endpoint, $data); } public function delete(string $endpoint): array { return $this->request('DELETE', $endpoint); } public function uploadFile(string $filePath, string $teamId): array { $data = [ 'file' => new CURLFile($filePath), 'teamId' => $teamId, ]; return $this->request('POST', '/upload', $data, true); } private function request( string $method, string $endpoint, ?array $data = null, bool $isMultipart = false ): array { $ch = curl_init(); $url = $this->baseUrl . $endpoint; $headers = ['x-api-key: ' . $this->apiKey]; if (!$isMultipart) { $headers[] = 'Content-Type: application/json'; } curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_CUSTOMREQUEST => $method, ]); if ($data !== null) { curl_setopt( $ch, CURLOPT_POSTFIELDS, $isMultipart ? $data : json_encode($data) ); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($curlError) { throw new RuntimeException("cURL error: {$curlError}"); } $result = json_decode($response, true); if ($httpCode === 429) { throw new RuntimeException( 'Rate limited. Back off and retry with exponential backoff.' ); } if ($httpCode >= 400) { throw new BundleSocialException( $result['message'] ?? 'Unknown API error', $httpCode, $result['errorsVerbose'] ?? null ); } return $result; } } class BundleSocialException extends RuntimeException { private ?array $errorsVerbose; public function __construct( string $message, int $code, ?array $errorsVerbose = null ) { parent::__construct($message, $code); $this->errorsVerbose = $errorsVerbose; } /** Platform-specific error details (null for non-post errors) */ public function getErrorsVerbose(): ?array { return $this->errorsVerbose; } }
Usage:
$client = new BundleSocialClient('pk_live_your_api_key_here');
API keys are org-scoped
One key gives access to all teams under your organization. Store it in environment variables, never hardcode it. See API key docs for details.
Teams: Create Workspaces
Teams are isolated workspaces - each team has its own social accounts, posts, and uploads. Think of them as your "client" or "workspace."
Create a Team
$team = $client->post('/team', [ 'name' => 'Acme Corp Marketing', ]); echo "Team ID: " . $team['id'] . "\n"; // Save this ID - you'll need it for everything else
List Teams
$teams = $client->get('/team', [ 'limit' => 20, 'offset' => 0, 'search' => 'Acme', // optional search filter ]); foreach ($teams['items'] as $team) { echo $team['name'] . " (ID: " . $team['id'] . ")\n"; }
One Organization, many Teams.
Don't create separate bundle.social accounts per client. Create Teams programmatically. All billing flows through your single subscription. For the full architecture breakdown, see our Multi-Tenant Architecture Guide.
Connect Social Accounts
Instead of building OAuth flows for 14+ platforms (please don't), use our hosted portal:
$portal = $client->post('/social-account/create-portal-link', [ 'teamId' => $teamId, 'redirectUrl' => 'https://yourapp.com/dashboard', 'socialAccountTypes' => ['INSTAGRAM', 'TIKTOK', 'LINKEDIN', 'YOUTUBE'], // White label it 'logoUrl' => 'https://yourapp.com/logo.png', 'hidePoweredBy' => true, 'language' => 'en', // supports: en, pl, fr, de, es, it, nl, pt, ru, tr, zh, hi, sv ]); // Redirect your user to this URL header('Location: ' . $portal['url']); exit;
The user completes OAuth on our branded page (with YOUR logo), then gets redirected back to your redirectUrl. We handle token storage, refresh, and permission scopes.
Pro tip:
For YouTube, Facebook, Instagram, LinkedIn, and Google Business, users also need to select a channel/page after OAuth. The hosted portal handles this automatically. See the Connect Social Accounts docs for the full flow.
Upload Media
Two methods. Pick based on file size.
Simple Upload (Images & Small Videos)
Good for anything under 90 MB. Standard multipart/form-data:
$upload = $client->uploadFile('/path/to/image.jpg', $teamId); echo "Upload ID: " . $upload['id'] . "\n"; echo "Type: " . $upload['type'] . "\n"; // "image" or "video" echo "Size: " . $upload['fileSize'] . " bytes\n";
Resumable Upload (Large Videos)
For big files. Three steps: init, push bytes, finalize.
// Step 1: Initialize - tell us what's coming $init = $client->post('/upload/init', [ 'teamId' => $teamId, 'fileName' => 'big-video.mp4', 'mimeType' => 'video/mp4', // video/mp4, image/jpg, image/jpeg, image/png, application/pdf ]); $uploadUrl = $init['url']; // Pre-signed URL (expires in 10 minutes!) $path = $init['path']; // Keep this for Step 3 // Step 2: Push bytes to the signed URL (raw PUT, no auth headers needed) $filePath = '/path/to/big-video.mp4'; $fileHandle = fopen($filePath, 'r'); $fileSize = filesize($filePath); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $uploadUrl, CURLOPT_PUT => true, CURLOPT_INFILE => $fileHandle, CURLOPT_INFILESIZE => $fileSize, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: video/mp4'], ]); curl_exec($ch); curl_close($ch); fclose($fileHandle); // Step 3: Finalize - register the file in our system $upload = $client->post('/upload/finalize', [ 'teamId' => $teamId, 'path' => $path, ]); echo "Upload ID: " . $upload['id'] . "\n";
The pre-signed URL expires after 10 minutes.
Don't go make coffee between Step 1 and Step 2. If you do, just re-initialize. Also: use resumable for any video. If a 1 GB upload fails at 99% with the simple method, you start over. With resumable, you don't.
For supported formats and per-platform size limits, see Platform Limits.
Create Posts
Every post needs a teamId, a title, a postDate (ISO 8601), a status, the target platforms, and platform-specific data.
Single Platform: Instagram Reel
$post = $client->post('/post', [ 'teamId' => $teamId, 'title' => 'Behind the scenes', 'postDate' => date('c'), // ISO 8601, current time = post now 'status' => 'SCHEDULED', 'socialAccountTypes' => ['INSTAGRAM'], 'data' => [ 'INSTAGRAM' => [ 'type' => 'REEL', // POST, REEL, or STORY 'text' => 'Behind the scenes footage #bts #reels', // max 2000 chars 'uploadIds' => [$upload['id']], 'shareToFeed' => true, // show Reel in feed grid ], ], ]); echo "Post ID: " . $post['id'] . "\n"; echo "Status: " . $post['status'] . "\n";
Multiple Platforms at Once
Each platform gets its own data block with platform-specific fields:
$post = $client->post('/post', [ 'teamId' => $teamId, 'title' => 'New product launch', 'postDate' => '2026-03-01T10:00:00Z', // schedule for later 'status' => 'SCHEDULED', 'socialAccountTypes' => ['INSTAGRAM', 'TIKTOK', 'YOUTUBE', 'LINKEDIN'], 'data' => [ 'INSTAGRAM' => [ 'type' => 'REEL', 'text' => 'It\'s here. #launch #newproduct', 'uploadIds' => [$uploadId], ], 'TIKTOK' => [ 'text' => 'We made a thing. #fyp #launch', // max 2200 chars 'uploadIds' => [$uploadId], 'privacy' => 'PUBLIC_TO_EVERYONE', // Also available: SELF_ONLY, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR ], 'YOUTUBE' => [ 'type' => 'SHORT', // SHORT or VIDEO 'text' => 'New Product Launch', // this is the video TITLE (max 100 chars) 'description' => 'Check out our latest product.', // max 5000 chars 'uploadIds' => [$uploadId], 'privacy' => 'PUBLIC', // PUBLIC, PRIVATE, or UNLISTED 'madeForKids' => false, ], 'LINKEDIN' => [ 'text' => 'Excited to announce our latest launch. Full details in the comments.', // text is REQUIRED for LinkedIn (max 3000 chars) 'uploadIds' => [$uploadId], 'privacy' => 'PUBLIC', // PUBLIC, CONNECTIONS, LOGGED_IN, CONTAINER ], ], ]);
Text-Only Posts (No Media)
$post = $client->post('/post', [ 'teamId' => $teamId, 'title' => 'Quick update', 'postDate' => date('c'), 'status' => 'SCHEDULED', 'socialAccountTypes' => ['TWITTER', 'LINKEDIN'], 'data' => [ 'TWITTER' => [ 'text' => 'We just shipped v2.0. That is all.', // max 280 chars (25K for Premium) ], 'LINKEDIN' => [ 'text' => 'We just shipped v2.0. Here\'s what changed and why it matters for your workflow.', ], ], ]);
Facebook: Posts, Reels & Stories
// Facebook page post with a link $post = $client->post('/post', [ 'teamId' => $teamId, 'title' => 'Blog share', 'postDate' => date('c'), 'status' => 'SCHEDULED', 'socialAccountTypes' => ['FACEBOOK'], 'data' => [ 'FACEBOOK' => [ 'type' => 'POST', // POST, REEL, or STORY 'text' => 'New on the blog: How we reduced video upload failures by 80%.', 'link' => 'https://yoursite.com/blog/video-uploads', // only for type POST ], ], ]);
Platform-specific fields vary.
Instagram has collaborators and tagged users. TikTok has disableComments, disableDuet, disableStitch, isAiGenerated. Pinterest requires a boardId. Check the Platform Guides for every field per platform.
Post Status Values
| Status | When to use |
|---|---|
SCHEDULED | With a future postDate - publishes at that time |
SCHEDULED | With current postDate (date('c')) - publishes immediately |
DRAFT | Save without publishing. Edit and schedule later |
Webhooks
After publishing, posts transition to POSTED, ERROR, or PROCESSING. You can't set these - they're system-managed. Listen for post.published and post.failed webhooks to track status.
List & Manage Posts
List Posts
$posts = $client->get('/post', [ 'teamId' => $teamId, // required 'status' => 'SCHEDULED', // DRAFT, SCHEDULED, POSTED, ERROR, PROCESSING, REVIEW, RETRYING 'limit' => 20, 'offset' => 0, 'order' => 'DESC', // ASC or DESC 'orderBy' => 'postDate', // createdAt, updatedAt, postDate, postedDate ]); echo "Total: " . $posts['total'] . "\n"; foreach ($posts['items'] as $post) { echo $post['title'] . " - " . $post['status'] . " - " . $post['postDate'] . "\n"; }
Delete a Post
$client->delete('/post/' . $postId);
Retry a Failed Post
$client->post('/post/' . $postId . '/retry', []);
Analytics
Analytics require a teamId and platformType. The response contains a items array - each item is a daily analytics snapshot (refreshed every 24h, retained for 40 days).
Analytics are available on paid tiers only
(PRO and BUSINESS). Not available for Twitter/X, Discord, or Slack. See the Analytics docs for per-platform availability.
Social Account Analytics
$analytics = $client->get('/analytics/social-account', [ 'teamId' => $teamId, 'platformType' => 'INSTAGRAM', // Valid: INSTAGRAM, FACEBOOK, LINKEDIN, TIKTOK, YOUTUBE, // THREADS, PINTEREST, REDDIT, MASTODON, BLUESKY, GOOGLE_BUSINESS ]); // $analytics['socialAccount'] - the social account object // $analytics['items'] - array of analytics snapshots (newest last) $latest = end($analytics['items']); echo "Followers: " . $latest['followers'] . "\n"; echo "Impressions: " . $latest['impressions'] . "\n"; echo "Views: " . $latest['views'] . "\n"; echo "Likes: " . $latest['likes'] . "\n"; echo "Comments: " . $latest['comments'] . "\n"; echo "Post Count: " . $latest['postCount'] . "\n";
Post Analytics
$postAnalytics = $client->get('/analytics/post', [ 'postId' => $postId, 'platformType' => 'INSTAGRAM', ]); // $postAnalytics['post'] - the post object // $postAnalytics['items'] - array of analytics snapshots $latest = end($postAnalytics['items']); echo "Impressions: " . $latest['impressions'] . "\n"; echo "Likes: " . $latest['likes'] . "\n"; echo "Comments: " . $latest['comments'] . "\n"; echo "Shares: " . $latest['shares'] . "\n"; echo "Saves: " . $latest['saves'] . "\n"; echo "Views: " . $latest['views'] . "\n";
Force Refresh Analytics
// Rate limited to (number of teams × 5) per day $client->post('/analytics/social-account/force', [ 'teamId' => $teamId, 'platformType' => 'INSTAGRAM', ]);
Some metrics return 0.
This doesn't mean zero engagement - it means the platform API doesn't provide that data point. For example, Twitter/X has no analytics API at all, and YouTube doesn't expose follower counts via API. Each Platform Guide documents exactly which fields return 0 and why.
Webhooks: Signature Verification
Webhooks fire at the organization level. All events from all teams hit the same endpoints. Always verify the signature - we send an HMAC-SHA256 hash in the x-signature header.
Plain PHP
<?php // Get raw body BEFORE any parsing $body = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_SIGNATURE'] ?? ''; $secret = getenv('BUNDLE_WEBHOOK_SECRET'); // your webhook secret // Verify HMAC-SHA256 signature $expected = hash_hmac('sha256', $body, $secret); if (!hash_equals($expected, $signature)) { http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit; } // Signature valid - process the event $event = json_decode($body, true); switch ($event['type']) { case 'post.published': // Post went live $postId = $event['data']['id']; $teamId = $event['data']['teamId']; $status = $event['data']['status']; // "POSTED" // Update your database, notify the user break; case 'post.failed': // Post failed after retries $postId = $event['data']['id']; // Alert the user, log error details break; case 'social-account.created': // New social account connected $accountType = $event['data']['type']; // "INSTAGRAM", "TIKTOK", etc. $teamId = $event['data']['teamId']; $username = $event['data']['username']; // Update available platforms in your UI break; case 'social-account.deleted': // Account disconnected - remove from your UI break; case 'team.created': case 'team.updated': // team.updated also fires when social accounts are added/removed break; case 'team.deleted': // Clean up your database break; case 'comment.published': // Auto-comment posted break; } http_response_code(200); echo 'ok';
Webhook Delivery Details
| Setting | Value |
|---|---|
| Timeout | 15 seconds per delivery |
| Max attempts | 3 (initial + 2 retries) |
| Backoff | Exponential, starting at 30s |
| Auto-disable | After 50 consecutive failures in 24h |
Respond fast.
Return a 200 within 15 seconds. Do heavy processing asynchronously - push to a queue (Redis, RabbitMQ, database job table). If your handler is slow, we count it as a failure. 50 consecutive failures and we auto-disable your webhook. See the Webhooks docs for full payload examples.
Error Handling
When a post fails on specific platforms, the response includes errorsVerbose - a per-platform breakdown of what went wrong:
try { $post = $client->post('/post', $postData); echo "Post created: " . $post['id'] . "\n"; } catch (BundleSocialException $e) { echo "Error: " . $e->getMessage() . "\n"; echo "HTTP Code: " . $e->getCode() . "\n"; $verbose = $e->getErrorsVerbose(); if ($verbose) { // Each platform has its own error entry foreach ($verbose as $platform => $error) { if ($error === null) { echo "{$platform}: Success\n"; continue; } echo "{$platform}: {$error['userFacingMessage']}\n"; echo " Code: {$error['code']}\n"; // e.g. "META:190", "TT:spam_risk" echo " Transient: " . ($error['isTransient'] ? 'yes (retry)' : 'no (fix it)') . "\n"; echo " Raw: {$error['errorMessage']}\n"; // upstream platform error } } }
Error Code Prefixes
| Prefix | Platform |
|---|---|
META | Instagram, Facebook, Threads |
TT | TikTok |
LI | |
YT | YouTube |
HTTP | Generic API errors |
The isTransient Field
| Value | Meaning | Action |
|---|---|---|
true | Rate limit, temporary outage, timeout | Retry with exponential backoff |
false | Auth error, content rejected, validation | Fix the input or reconnect the account |
For the full error reference, see the Errors docs.
Rate Limiting
Two layers. Both matter.
Layer 1: API Rate Limits
| Layer | Window | Max Requests |
|---|---|---|
| Burst | 1 second | 100 |
| Short | 10 seconds | 500 |
| Minute | 1 minute | 2,000 |
Tracked per API key. Hit any limit → 429 Too Many Requests. Implement exponential backoff:
function requestWithRetry(BundleSocialClient $client, string $endpoint, array $data, int $maxRetries = 3): array { for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { try { return $client->post($endpoint, $data); } catch (RuntimeException $e) { if ($e->getCode() === 429 && $attempt < $maxRetries) { $wait = pow(2, $attempt) + random_int(0, 1000) / 1000; // jitter sleep((int) $wait); continue; } throw $e; } } }
Layer 2: Platform Posting Limits
Daily caps per social account per platform (varies by subscription tier):
| Platform | FREE | PRO | BUSINESS |
|---|---|---|---|
| 10/day | 20/day | 25/day | |
| TikTok | 5/day | 10/day | 15/day |
| Twitter/X | 5/day | 15/day | 15/day |
| YouTube | 10/day | 10/day | 15/day |
Plus monthly org-wide caps: FREE = 10, PRO = 1,000, BUSINESS = 100,000.
Full Example: Laravel Integration
Service Class
<?php // app/Services/BundleSocialService.php namespace App\Services; use Illuminate\Support\Facades\Http; use Illuminate\Http\Client\Response; class BundleSocialService { private string $baseUrl = 'https://api.bundle.social/api/v1'; private function headers(): array { return ['x-api-key' => config('services.bundlesocial.api_key')]; } public function createTeam(string $name): array { return Http::withHeaders($this->headers()) ->post("{$this->baseUrl}/team", ['name' => $name]) ->throw() ->json(); } public function uploadMedia(string $filePath, string $teamId): array { return Http::withHeaders($this->headers()) ->attach('file', file_get_contents($filePath), basename($filePath)) ->post("{$this->baseUrl}/upload", ['teamId' => $teamId]) ->throw() ->json(); } public function createPost(array $data): array { return Http::withHeaders($this->headers()) ->post("{$this->baseUrl}/post", $data) ->throw() ->json(); } public function getAnalytics(string $teamId, string $platform): array { return Http::withHeaders($this->headers()) ->get("{$this->baseUrl}/analytics/social-account", [ 'teamId' => $teamId, 'platformType' => $platform, ]) ->throw() ->json(); } public function getPortalLink(string $teamId, array $platforms): string { $response = Http::withHeaders($this->headers()) ->post("{$this->baseUrl}/social-account/create-portal-link", [ 'teamId' => $teamId, 'redirectUrl' => route('dashboard'), 'socialAccountTypes' => $platforms, 'hidePoweredBy' => true, ]) ->throw() ->json(); return $response['url']; } }
Webhook Controller
<?php // app/Http/Controllers/WebhookController.php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Http\Response; class WebhookController extends Controller { public function handle(Request $request): Response { // Verify signature $signature = $request->header('x-signature', ''); $expected = hash_hmac( 'sha256', $request->getContent(), config('services.bundlesocial.webhook_secret') ); if (!hash_equals($expected, $signature)) { abort(401, 'Invalid signature'); } $event = $request->json()->all(); match ($event['type']) { 'post.published' => $this->handlePostPublished($event['data']), 'post.failed' => $this->handlePostFailed($event['data']), 'social-account.created' => $this->handleAccountCreated($event['data']), 'social-account.deleted' => $this->handleAccountDeleted($event['data']), default => null, }; return response('ok', 200); } private function handlePostPublished(array $data): void { // Update your DB, notify the user // $data['id'], $data['teamId'], $data['status'], $data['postedDate'] } private function handlePostFailed(array $data): void { // Alert user, log failure details } private function handleAccountCreated(array $data): void { // $data['type'] = "INSTAGRAM", "TIKTOK", etc. // $data['teamId'], $data['username'] } private function handleAccountDeleted(array $data): void { // Remove from your UI } }
Laravel route tip:
Register the webhook route outside the web middleware group (no CSRF verification needed). Add it in routes/api.php or exclude it from CSRF in your middleware.
Config
// config/services.php 'bundlesocial' => [ 'api_key' => env('BUNDLESOCIAL_API_KEY'), 'webhook_secret' => env('BUNDLESOCIAL_WEBHOOK_SECRET'), ],
Platform-Specific Fields Reference
Quick reference for the most commonly used platform-specific fields. For the full spec, see Platform Guides.
| Platform | Key Fields | Notes |
|---|---|---|
type (POST/REEL/STORY), text, uploadIds, shareToFeed, collaborators, tagged | text max 2000 chars. collaborators max 3 usernames | |
| TikTok | type (VIDEO/IMAGE), text, uploadIds, privacy, disableComments, isAiGenerated | text max 2200 chars. IMAGE type: JPG only |
| YouTube | type (SHORT/VIDEO), text (title!), description, uploadIds, privacy, madeForKids | text is the video TITLE (max 100). description max 5000 |
text (required!), uploadIds, privacy, mediaTitle, hideFromFeed | text max 3000 chars. Supports PDF documents | |
| Twitter/X | text, uploadIds | 280 chars (Free/Basic), 25K chars (Premium) |
type (POST/REEL/STORY), text, uploadIds, link | link only for type POST. text max 50K chars | |
text, description, uploadIds, boardName (required!), link | text max 100 chars. boardName from socialAccount.channels | |
sr (required!), text, uploadIds, flairId, link, nsfw | sr format: r/subredditName or u/username. text max 300 chars |
Questions? Running into edge cases with PHP specifically? Reach out - we've seen enough curl_setopt configurations to last a lifetime.