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 - with native cURL and Laravel examples.
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.
Here’s a minimal client that handles auth, JSON encoding, multipart uploads, and proper error surfacing:
Copy
<?phpclass 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:
Copy
$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.
$team = $client->post('/team', [ 'name' => 'Acme Corp Marketing',]);echo "Team ID: " . $team['id'] . "\n";// Save this ID - you'll need it for everything else
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.
Instead of building OAuth flows for 14+ platforms (please don’t), use our hosted portal:
Copy
$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 URLheader('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.
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.
// 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.
With current postDate (date('c')) - publishes immediately
DRAFT
Save without publishing. Edit and schedule later
After publishing, posts transition to POSTED, ERROR, or PROCESSING. You can’t set these - they’re system-managed. Listen for post.published and post.failedwebhooks to track status.
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.
// 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 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.
<?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';
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.
<?php// app/Http/Controllers/WebhookController.phpnamespace 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.