Skip to main content
By Marcel Czuryszkiewicz, Founder @ bundle.social

TL;DR

  • This is the implementation guide, not the sales pitch. For “why API-first,” read our white label management guide.
  • bundle.social’s multi-tenant model: Organization → Teams → Social Accounts. Your “client” maps to our “team.”
  • API keys are scoped at the organization level. One key, all your clients’ data.
  • Two-layer rate limiting: our API limits + per-platform daily posting limits. Handle both.
  • Webhooks fire at the organization level - post.published, post.failed, social-account.created, and more.
  • Full end-to-end flow from user signup to first published post, with production-ready code.

Who This Is For

You’ve already decided to build a social media SaaS (or white-label tool) on top of an API. You don’t need another comparison table - you need architecture diagrams, data model mappings, and code that works in production. If you’re still weighing your options, start here: Still here? Good. Let’s build.

The Data Model

Here’s how bundle.social organizes multi-tenant data:
Organization (your account)
├── API Keys (up to 50, org-scoped)
├── Webhooks (up to 5, org-scoped)
├── Subscription (FREE / PRO / BUSINESS)

├── Team "Client A"
│   ├── Social Accounts (Instagram, TikTok, LinkedIn...)
│   ├── Posts
│   └── Uploads

├── Team "Client B"
│   ├── Social Accounts (Twitter, Facebook...)
│   ├── Posts
│   └── Uploads

└── Team "Client C"
    └── ...
The key insight: our “Team” is your “client workspace.” Each team is an isolated container with its own social accounts, posts, and uploads. Teams can’t see each other’s data.
Your conceptbundle.social concept
Your SaaS accountOrganization
Your client / workspaceTeam
Client’s social profilesSocial Accounts (within a Team)
Your API credentialsAPI Key (org-scoped)
Your webhook endpointsWebhooks (org-scoped)
One Organization, many Teams. You don’t create separate bundle.social accounts for each client. Create one Organization, get your API key, and spin up Teams programmatically. All billing flows through your single subscription.

Mapping Your Users to Our Organizations

Here’s where architecture meets your database. You need to track which of YOUR users maps to which of OUR teams. Minimal schema (use whatever DB you like):
-- Your users table (you already have this)
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  name TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Map your users to bundle.social teams
CREATE TABLE workspaces (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES users(id),
  name TEXT NOT NULL,
  bundle_team_id TEXT NOT NULL,  -- bundle.social team ID
  created_at TIMESTAMP DEFAULT NOW()
);
When a user creates a workspace in your app:
import { BundleSocial } from 'bundlesocial';

const bundle = new BundleSocial(process.env.BUNDLE_API_KEY);

async function createWorkspace(userId: string, workspaceName: string) {
  // 1. Create team in bundle.social
  const team = await bundle.team.teamCreate({
    requestBody: { name: workspaceName }
  });

  // 2. Store the mapping in YOUR database
  await db.query(
    'INSERT INTO workspaces (id, user_id, name, bundle_team_id) VALUES ($1, $2, $3, $4)',
    [generateUUID(), userId, workspaceName, team.id]
  );

  return { workspaceId: team.id, name: workspaceName };
}
Your user never knows bundle.social exists. They see “Create Workspace” in your UI, and behind the scenes you’re creating a team via our API.

API Key Architecture

API keys are scoped to your Organization - not to individual teams. One key gives you access to all teams under your org.

How It Works

  1. Create API keys in the dashboard (up to 50 per org)
  2. Every public API request uses the x-api-key header
  3. We identify your organization from the key and scope all data accordingly
// Every request is automatically scoped to your org
const response = await fetch('https://api.bundle.social/api/v1/team', {
  headers: {
    'x-api-key': process.env.BUNDLE_API_KEY,
    'Content-Type': 'application/json',
  }
});
// Returns only YOUR teams, not anyone else's

Key Management

  • Rotate regularly. Use the roll endpoint to regenerate a key without downtime
  • Don’t share keys across environments. Separate keys for dev, staging, prod
  • Store in env variables. We hash keys with SHA-256 on our end, so even we can’t read them after creation
API keys are shown once. When you create or roll a key, we return the plaintext exactly once. After that, it’s hashed. If you lose it, roll a new one.

The Complete Flow: Signup to First Post

Here’s the full end-to-end walkthrough. New user signs up for your platform, connects their socials, and publishes a post. Every step maps to a real API call.

Step 1: User Signs Up → Create a Team

async function onUserSignup(user: { id: string; name: string }) {
  const team = await bundle.team.teamCreate({
    requestBody: { name: `${user.name}'s Workspace` }
  });

  // Store team ID in your database
  await db.workspaces.create({
    userId: user.id,
    bundleTeamId: team.id,
    name: team.name,
  });

  return team;
}
Instead of building OAuth flows for 14+ platforms yourself (please don’t), use our hosted connection portal:
const response = await fetch(
  'https://api.bundle.social/api/v1/social-account/create-portal-link',
  {
    method: 'POST',
    headers: {
      'x-api-key': process.env.BUNDLE_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      teamId: teamId,
      redirectUrl: 'https://yourapp.com/dashboard',
      socialAccountTypes: [
        'INSTAGRAM', 'TIKTOK', 'LINKEDIN',
        'FACEBOOK', 'YOUTUBE', 'TWITTER'
      ],
      // White label it
      logoUrl: 'https://yourapp.com/logo.png',
      hidePoweredBy: true,
    })
  }
);

const portal = await response.json();
// Redirect your user to portal.url
The user clicks through OAuth on our hosted page (branded with YOUR logo), then gets redirected back to your app. We handle token storage, refresh, permission scopes - all of it.
Want full control? You can build custom OAuth flows using POST /api/v1/social-account/connect to get OAuth URLs per platform. But tbh, the hosted portal saves weeks of work and handles edge cases you haven’t thought of yet. See the Connect Social Accounts docs for both approaches.

Step 3: Upload Media

const upload = await bundle.upload.uploadCreate({
  formData: {
    teamId: teamId,
    file: new Blob([videoBuffer], { type: 'video/mp4' }),
  }
});
// upload.id → use this in the post
We handle video transcoding, image resizing, and format validation per platform. Upload once, we optimize for each. See Upload Content for supported formats and size limits.

Step 4: Create the Post

const post = await bundle.post.postCreate({
  requestBody: {
    teamId: teamId,
    title: 'Your post title',
    postDate: '2026-02-15T10:00:00Z',
    status: 'SCHEDULED',
    socialAccountTypes: ['INSTAGRAM', 'TIKTOK'],
    data: {
      INSTAGRAM: {
        type: 'POST',
        text: 'Your content here #hashtag',
        uploadIds: [upload.id],
      },
      TIKTOK: {
        text: 'Your content here',
        uploadIds: [upload.id],
        privacy: 'PUBLIC_TO_EVERYONE',
      },
    },
  }
});
Each platform gets its own data block inside data. Instagram needs a type (POST, REEL, STORY), TikTok needs privacy, YouTube needs madeForKids, Pinterest needs a boardId, and so on. Platform-specific fields are all documented in our Platform Guides.
Status options: SCHEDULED with a future postDate publishes at that time. Set postDate to the current time with status: 'SCHEDULED' to post immediately. Use status: 'DRAFT' to save without publishing. See Platform Limits for per-platform constraints.

Webhook Integration

Webhooks fire at the organization level. All events from all teams hit the same webhook endpoints. You get up to 5 webhook URLs per organization.

Available Events

EventWhen it firesWhy you care
post.publishedPost went live on the platformUpdate your UI, notify the user
post.failedPost failed after retriesAlert the user, log the error
comment.publishedFirst comment was postedTrack auto-comment status
social-account.createdNew social account connectedUpdate available platforms in your UI
social-account.deletedAccount disconnectedRemove from your UI, alert the user
team.createdNew team createdSync with your workspace list
team.updatedTeam details changedAlso fires when social accounts are added/removed
team.deletedTeam deletedClean up your database

Handling Webhooks

import express from 'express';
import { BundleSocial } from 'bundlesocial';

const bundle = new BundleSocial(process.env.BUNDLE_API_KEY);
const app = express();

app.post(
  '/webhooks/bundle',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-signature'] as string;

    // Verify the signature - ALWAYS do this
    const event = bundle.webhooks.constructEvent(
      req.body,
      signature,
      process.env.BUNDLE_WEBHOOK_SECRET
    );

    switch (event.type) {
      case 'post.published':
        await db.posts.update({
          where: { bundlePostId: event.data.id },
          data: {
            status: 'published',
            publishedAt: event.data.postedDate,
          }
        });
        await notifyUser(event.data.teamId, 'Your post is live!');
        break;

      case 'post.failed':
        await db.posts.update({
          where: { bundlePostId: event.data.id },
          data: { status: 'failed' }
        });
        await notifyUser(event.data.teamId, 'Post failed - check the details');
        break;

      case 'social-account.created':
        await syncSocialAccounts(event.data.teamId);
        break;

      case 'social-account.deleted':
        await removeSocialAccount(event.data.teamId, event.data.id);
        break;
    }

    res.status(200).send('ok');
  }
);

Delivery & Reliability

SettingValue
Timeout15 seconds per delivery
Max attempts3 (initial + 2 retries)
BackoffExponential, starting at 30s
Auto-disableAfter 50 consecutive failures in 24h
Respond fast. Return a 200 within 15 seconds. Do heavy processing asynchronously - queue it up. If your handler takes too long, we count it as a failure. 50 consecutive failures and we auto-disable your webhook.
For full payload examples and signature verification, see the Webhooks docs.

Two-Layer Rate Limiting

This trips up most developers. There are TWO separate rate limiting systems, and you need to handle both.

Layer 1: API Rate Limits (Our Infrastructure)

These protect the API from getting hammered:
LayerWindowMax Requests
Burst1 second100
Short10 seconds500
Minute1 minute2,000
All three enforced simultaneously, tracked per API key. Hit any of them → 429 Too Many Requests. Back off exponentially.

Layer 2: Platform Posting Limits (Per Social Account, Per Day)

Daily caps on how many posts each connected account can make, varying by subscription tier:
PlatformFREEPROBUSINESS
Instagram10/day20/day25/day
TikTok5/day10/day15/day
Twitter/X5/day15/day15/day
LinkedIn10/day18/day24/day
YouTube10/day10/day15/day
Facebook10/day24/day36/day
Plus monthly organization-wide caps: FREE = 10 posts, PRO = 1,000, BUSINESS = 100,000.

Building Your Own Limit Layer

Here’s the thing - you probably want YOUR OWN limits on top of ours. If your “Starter” plan allows 100 posts/month but your bundle.social plan allows 1,000, that’s your problem to enforce.
async function canUserPost(workspaceId: string): Promise<boolean> {
  // Check YOUR limits first
  const usage = await db.query(
    'SELECT COUNT(*) FROM posts WHERE workspace_id = $1 AND created_at > NOW() - INTERVAL \'30 days\'',
    [workspaceId]
  );

  const plan = await getUserPlan(workspaceId);
  if (usage.count >= plan.monthlyPostLimit) {
    throw new Error('Monthly post limit reached. Upgrade your plan.');
  }

  // If your check passes, the API will enforce its own limits
  // Handle 400/429 responses gracefully
  return true;
}
Keep it simple. A counter in your database, reset monthly, is all you need. Don’t over-engineer this. Check your limits before hitting our API, and handle our error responses for the rest.
For the full rate limit breakdown, see Rate Limits.

The OAuth Problem (Solved)

Connecting social accounts is the hardest part of any social media integration. Each platform has different OAuth flows, token formats, scopes, and refresh cycles. We’ve been dealing with this in production across 14+ platforms, so you don’t have to. The create-portal-link endpoint generates a branded page where users connect accounts:
User clicks "Connect Instagram" in YOUR app
  → Redirect to portal URL (with your logo, no bundle.social branding)
    → User completes OAuth on Instagram
      → Redirect back to YOUR app
        → Webhook fires: social-account.created
The portal supports:
  • Custom logo (logoUrl)
  • Hidden “Powered by” (hidePoweredBy: true)
  • Custom back button text (goBackButtonText)
  • 13 languages (en, pl, fr, de, es, it, nl, pt, ru, tr, zh, hi, sv)
  • Platform filtering - only show the platforms you want per session
  • Connection limit - cap how many accounts a user can connect (maxSocialAccountsConnected)

Channel Selection

For YouTube, Facebook, Instagram, LinkedIn, and Google Business, there’s an extra step after OAuth - the user needs to pick which page/channel/location to use. The hosted portal handles this automatically. If you’re building a custom OAuth flow, you’ll need to call POST /api/v1/social-account/set-channel yourself. See the Connect Social Accounts docs for both flows in detail.

Production Checklist

Before you ship, run through this. Trust me.

Security

  • API key in environment variables, not hardcoded
  • Webhook signatures verified on every delivery
  • HTTPS on your webhook endpoint
  • No API keys in client-side code (all calls from your backend)

Data Integrity

  • Team IDs mapped correctly in your database
  • Error handling for failed API calls (especially post creation)
  • Idempotent webhook handlers (we may deliver events more than once)

Limits & Monitoring

  • Your own rate limiting layer on top of ours
  • Monthly usage tracking surfaced to your users
  • Alerting when approaching plan limits (80% threshold)

User Experience

  • Graceful error messages when posts fail (platform-specific errors from post.failed webhook)
  • Social account reconnection flow (tokens expire, especially Instagram and TikTok)
  • Clear feedback on which platforms are connected per workspace

Webhooks

  • post.published and post.failed handled at minimum
  • Fast response times (< 15s, async processing for anything heavy)
  • Monitoring for consecutive failures (we auto-disable at 50)

Next Steps

Architecture done. Now go ship it.
Already building? Running into edge cases? Check the API docs or reach out - we’ve been running this in production long enough to know where the gotchas hide.