Multi-Tenant Social Media API Architecture: Build Your SaaS Backend the Right Way
Architecture guide for developers building multi-tenant social media platforms. Data models, API key scoping, webhooks, rate limits, and production-ready code.
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:
- White Label Social Media Management - why API-first beats traditional dashboards
- White Label Social Media Posting - the complete guide for agencies
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 concept | bundle.social concept |
|---|---|
| Your SaaS account | Organization |
| Your client / workspace | Team |
| Client's social profiles | Social Accounts (within a Team) |
| Your API credentials | API Key (org-scoped) |
| Your webhook endpoints | Webhooks (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
- Create API keys in the dashboard (up to 50 per org)
- Every public API request uses the
x-api-keyheader - 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.
bundle.social
Start building with our Social Media API
One API to schedule, publish manage and analyze content across your social media channels at scale.
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; }
Step 2: Connect Social Accounts → Portal Link
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
| Event | When it fires | Why you care |
|---|---|---|
post.published | Post went live on the platform | Update your UI, notify the user |
post.failed | Post failed after retries | Alert the user, log the error |
comment.published | First comment was posted | Track auto-comment status |
social-account.created | New social account connected | Update available platforms in your UI |
social-account.deleted | Account disconnected | Remove from your UI, alert the user |
team.created | New team created | Sync with your workspace list |
team.updated | Team details changed | Also fires when social accounts are added/removed |
team.deleted | Team deleted | Clean 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
| 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 - 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:
| Layer | Window | Max Requests |
|---|---|---|
| Burst | 1 second | 100 |
| Short | 10 seconds | 500 |
| Minute | 1 minute | 2,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:
| 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 |
| 10/day | 18/day | 24/day | |
| YouTube | 10/day | 10/day | 15/day |
| 10/day | 24/day | 36/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.
Hosted Portal (Recommended)
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.failedwebhook) - Social account reconnection flow (tokens expire, especially Instagram and TikTok)
- Clear feedback on which platforms are connected per workspace
Webhooks
-
post.publishedandpost.failedhandled 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.