Blog

APIs for Managing Multiple Social Media Accounts (2026)

Erwan Prost

Erwan Prost

· 20 min read

On this page

Running fifty client accounts through one API comes down to two objects. A brand groups everything belonging to one client. An API key carries a brand_ids array that decides which brands that key can reach. SocialAPI.ai gives you both, and the interesting part is the failure mode: a key restricted to Acme's brand does not answer 403 when it reaches for Globex. It answers a plain 404, the same reply it would give for a brand that never existed.

That silence is the isolation guarantee, so put it first. Out-of-brand reads are indistinguishable from nonexistent resources, list endpoints quietly return only the rows inside the allowed brands, and a post is reachable only through its target accounts. What a restricted key cannot do runs wider than most buyers expect. It cannot mint another key, cannot create or delete a brand, and cannot manage brand invites. Escalation is closed at the tier rather than patched per endpoint.

APIs for managing multiple social media accounts

Every product in this category answers one question before it answers anything else: what is the unit of tenancy? Some vendors make it a workspace. Some a profile, some a seat. Ours is the Brand, which our pricing page calls a social profile because that is the word buyers search for. One brand corresponds to one business, client, or location, and every connected account, post, and interaction lives under exactly one of them.

Brands do two jobs at once. They meter billing, since plans are sized in brands and nothing else. They also draw the isolation line, because accounts under one brand cannot see accounts under another. One object carrying both jobs is convenient right up to the moment an agency wants a fifty-first client on a plan sized for fifty. At that point the tenancy model and the invoice become the same conversation.

One agency account, one brand per client, isolated keys
ONE AGENCY ACCOUNT, ONE BRAND PER CLIENT, ISOLATED KEYSAgency accountone login, oneinvoiceFull-access keymints androtates keysAcme bot keyinbox:read,inbox:writeGlobex bot keyposts:read,posts:writeBrandsone per client,plan-cappedAcme accountsInstagram,TikTok, LinkedInGlobexaccountsFacebook, YouTubeInitech accountsinvite pendingCross-brand read returns 404Invite sent, no tokens yet

The left column is a single login and the keys minted under it. The middle is where tenancy is decided. The right is what each client actually connected through their own OAuth grant. A key carrying brand_ids sees exactly one column on the right and gets a 404 for the rest.

The right-hand column spans nine platforms on our side: Instagram, Facebook, Threads, TikTok, YouTube, X/Twitter, LinkedIn, Telegram, and Google Business Profile. Each one keeps its own OAuth window, token lifetime, and app-review process underneath the hub. What that costs to wire up yourself, per platform, is priced out in social media API integration.

bash
# Provision a client. Brands are UUIDs; accounts are prefixed acc_.
curl -X POST https://api.social-api.ai/v1/brands \
  -H "Authorization: Bearer $SOCAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Corp"}'

# { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
#   "name": "Acme Corp",
#   "created_at": "2026-08-06T09:30:00Z" }

# GET /v1/brands returns every brand a full-access key can see, and
# only the allowed ones for a brand-restricted key. Same endpoint,
# filtered result, no error.
curl https://api.social-api.ai/v1/brands \
  -H "Authorization: Bearer $SOCAPI_KEY"

One sharp edge before you write the provisioning job. POST /v1/brands is not idempotent: there is no external_id, no idempotency key, and brand names are not unique. A retried create leaves you holding two brands for one client and a plan counter that agrees with neither. Persist the returned brand ID the instant you get it, and check for an existing brand before creating another.

Letting a client connect their own accounts without a shared password

An agency that collects client passwords owns a compliance problem and a rotation problem. Invites remove both. POST /v1/invites takes a brand_id, a platform, and an expires_in_days of 1, 3, 7, 14, or 30, then returns a token and a url under api.social-api.ai/invite/. You send the link. The client opens it, lands on the platform's own OAuth screen, consents, and comes back connected to your brand.

bash
curl -X POST https://api.social-api.ai/v1/invites \
  -H "Authorization: Bearer $SOCAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "brand_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "platform": "instagram",
    "expires_in_days": 7
  }'

# 201
# { "id": "inv_01HZ9X3Q4R5M6N7P8V2K0W1J",
#   "platform": "instagram",
#   "token": "a55755cf75f73b56deb3dfe0c993fd36fc4c2bfa7",
#   "url": "https://api.social-api.ai/invite/a55755cf...",
#   "expires_at": "2026-08-13T12:00:00Z" }

# Audit trail per client, active and spent links together:
curl "https://api.social-api.ai/v1/invites?brand_id=3fa85f64-..." \
  -H "Authorization: Bearer $SOCAPI_KEY"

Redemption is GET /v1/invite/{token}, a public endpoint with no auth, which is exactly why the token is the credential and the expiry is doing real work. A token that does not exist answers 404. One already spent or past its expires_at answers 410, so your onboarding page can tell the difference between a typo and a stale link somebody forwarded from a group chat.

  • Invites are single-use. GET /v1/invites?brand_id=... lists active and spent links together with is_active, created_at, and expires_at, which is the audit trail an agency needs when a client asks who connected what.
  • The create call refuses with 409 when the brand already holds a connected account on that platform, or when an unused, unexpired invite exists for the same brand and platform pair. You cannot issue two live links for one slot by accident.
  • DELETE /v1/invites/{id} revokes a link that has not been redeemed. Send it the moment a client contact changes, rather than waiting out the 30-day ceiling.
  • Managing invites is an admin operation. A restricted key cannot create, list, or revoke them, so the onboarding service holds a full-access key and the per-client bots never do.

Social media APIs for agencies with client workspaces

The phrase client workspaces usually bundles four separate asks: separate credentials per client, separate data, separate reporting, and a separate blast radius when a credential leaks. Brands cover the middle two. Restricted keys cover the outer two. Nothing in the model asks you to create a sub-account, a child organization, or a second invoice, which is the part that surprises teams arriving from per-profile vendors like Ayrshare.

Plan tiers are sized in brands and in nothing else. Hobby is free and holds 2. $29/mo holds 10, $109/mo holds 50, and $349/mo holds 200, with posts and interactions unlimited above the free tier. So the only number an agency has to forecast is client count. At the top tier that arithmetic lands on $1.75 per client brand per month, which is the comparison to run against pricing that meters every connected account separately.

Where the model gets opinionated is that a brand is not a login. Your clients do not get seats, roles, or a shared dashboard from us. They get an invite link and, if you build it, whatever review screen your product puts in front of them. Everything a client sees is something you rendered from data you pulled with a key you scoped. That is more work than switching on a vendor's client portal, and it is the reason the isolation boundary below can be stated exactly.

One inbox across every client brand

Client workspaces are only useful if you can also collapse them. A support lead covering six clients wants one queue sorted by age, with the brand printed on each row, not six tabs. That is the unified social inbox shape: comments, DMs, reviews, and mentions all come back as the same Interaction object, and the ID prefix tells you which is which (sapi_cmt_, sapi_dm_, sapi_rev_, sapi_mnt_).

Fan-out is a loop over brands, and that loop is where multi-tenant bugs hide. GET /v1/inbox/comments takes brand_id directly, so tenancy is enforced by the request rather than by a filter you remember to apply afterwards. Tag every row with its brand before it enters the queue, and never let an untagged interaction reach a reply endpoint. A misrouted reply does more damage than a dropped one. Dropping a comment loses a customer; answering Acme's customer from Globex's handle loses two clients.

javascript
// One queue, six clients, brand carried on every row. The tag is set
// where the data enters, never inferred later from the interaction.
const queue = [];

for (const brand of (await sapi("/brands")).data) {
  const posts = await sapi("/inbox/comments?brand_id=" + brand.id);

  for (const post of posts.data) {
    const thread = await sapi(
      "/inbox/comments/" + post.id + "?account_id=" + post.account_id
    );

    for (const c of thread.data) {
      queue.push({
        brandId: brand.id,            // <- the routing tag, set once
        brandName: brand.name,
        accountId: post.account_id,
        postId: post.id,
        commentId: c.platform_id,
        platform: c.platform,
        text: c.text,
        canReply: c.capabilities.can_reply,
        at: c.created_at,
      });
    }
  }
}

queue.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));

// The reply asserts the tag instead of trusting whoever built the row.
// Each send costs 1 interaction credit, so a naive retry costs money.
function reply(row, text) {
  if (!row.brandId) throw new Error("untagged comment, refusing to reply");
  if (!row.canReply) throw new Error("platform blocks a reply here");
  return sapi("/inbox/comments/" + row.postId, {
    method: "POST",
    body: JSON.stringify({
      account_id: row.accountId,
      comment_id: row.commentId,
      text,
    }),
  });
}

Affordable social media API with approval workflows

We do not sell an approvals product, and saying so up front saves you an evaluation cycle. What we ship is the three calls an approval workflow is assembled from, plus a post state that can sit untouched for a week. If you are pricing this against a suite with a built-in review queue, the trade is real: you build the screen, you keep the state machine, and you stop paying for a workflow UI your customers will ask you to change anyway.

Start with the default, which is already the safe one. A post created with neither publish_now nor scheduled_at is a draft. Nothing reaches a platform, nothing is scheduled, and no credit moves. Adding scheduled_at with a future RFC3339 timestamp makes it scheduled. Setting publish_now: true with targets present sends it straight to publishing, which is the one call a review flow should never expose to a drafting bot.

POST /v1/posts/validate is the dry run in between. It checks character limits per platform, media count and type, schedule validity, and platform-specific required fields, then returns valid alongside errors for hard violations and warnings for best-practice ones. Every issue carries field, message, platform, segment_index, and target, so a reviewer sees Instagram, media count, target acc_01HZ... instead of one red banner.

bash
# 1. Draft. No publish_now, no scheduled_at, so nothing goes out.
curl -X POST https://api.social-api.ai/v1/posts \
  -H "Authorization: Bearer $AGENCY_KEY" -H "Content-Type: application/json" \
  -d '{"text": "Spring collection is live.",
       "targets": [{"account_id": "acc_01HZ9X3Q4R5M6N7P8V2K0W1J"}]}'
# { "id": "7c9e6679-...", "status": "draft" }

# 2. Dry run. Spends nothing, publishes nothing, safe to call on every
#    keystroke in the review screen.
curl -X POST https://api.social-api.ai/v1/posts/validate \
  -H "Authorization: Bearer $AGENCY_KEY" -H "Content-Type: application/json" \
  -d '{"text": "Spring collection is live.",
       "account_ids": ["acc_01HZ9X3Q4R5M6N7P8V2K0W1J"]}'
# { "valid": false,
#   "errors": [{ "field": "media_ids", "platform": "instagram",
#                "target": "acc_01HZ9X3Q4R5M6N7P8V2K0W1J",
#                "message": "instagram requires at least one media item" }],
#   "warnings": [] }

# 3. The gate. Accepts draft or scheduled only. 201, then publishing.
#    This is the call that spends 1 post credit.
curl -X POST https://api.social-api.ai/v1/posts/7c9e6679-.../publish \
  -H "Authorization: Bearer $AGENCY_KEY"

Delivery after that runs in the background across every target, and the post lands on published, partial, or failed. The full state machine is draft, scheduled, publishing, published, partial, failed, cancelled. A review screen that renders those seven states honestly, including partial, will beat one that shows a green check as soon as the publish call returns 201.

Now the part worth reading twice before you design the permission model. posts:write grants create, edit, publish, and delete as a single unit. No combination of scopes hands a drafting bot the right to propose without the right to ship. So the approval boundary lives in your service, not in ours: the copywriter's agent holds a posts:read key and calls your endpoint, your endpoint holds the posts:write key, and it calls us only after a human clicks approve.

Close the loop with webhooks instead of polling. The posts family covers the publishing lifecycle: post.published when every target succeeded, post.partial when some did, post.failed when none did, plus post.updated, post.deleted, and post.unpublished. Failed deliveries retry 5 times on an immediate, ~30s, ~5m, ~30m, ~3h schedule, so your handler has to be idempotent. GET /v1/webhooks/events returns the live catalog, which any valid key can call.

Two request fields deserve their own review before they reach production. skip_validation: true bypasses the pre-publish checks entirely. skip_duplicate_check: true bypasses duplicate detection. Both earn their place in a one-off migration script. Neither belongs anywhere near the path a client-facing approve button triggers, because the failure they produce is a live post on a client's account, not a 400 in your logs.

How scoped API keys keep one client out of another's data

By default every key has full access to every brand in the account. Restriction is opt-in, along two axes that combine freely: scopes decides which actions the key may perform, brand_ids decides which brands' resources it may see and touch. A key can be scoped, brand-restricted, both, or neither. Keys minted before the feature existed kept full access unchanged, which is worth checking against your own key list.

Read the empty-array rule twice, because it is the one that bites. An empty scopes array grants every scope. An empty brand_ids array grants every brand. A key becomes restricted only when at least one of the two arrays is non-empty. A provisioning bug that posts [] therefore mints an unrestricted key rather than a useless one, and the response echoes both arrays back so you can assert on them in a test.

ScopeWhat a key holding it can doWhat it still cannot do
accounts:readList brands, connected accounts, and their metadataConnect or disconnect anything. GET /v1/brands is allowed and filtered; creating a brand is not.
accounts:manageConnect, configure, and disconnect accountsCreate, update, or delete a brand, or manage that brand's invites. Both are admin-tier.
posts:readRead posts and their per-platform status and metricsSee a draft with no targets, when the key is brand-restricted. A post reaches a brand only through its targets.
posts:writeCreate, edit, publish, and delete postsWrite to a post whose targets are not all inside brand_ids. Refused as 404 account.not_found.
media:readBrowse the media libraryNothing brand-specific. The library is one account-level pool and is deliberately not partitioned.
media:writeUpload and delete mediaReach another brand through an upload. The file is inert until attached, and the attach is brand-checked.
inbox:readRead comments, reviews, and mentionsReply, hide, or delete. It also does not cover DMs, which sit in their own group.
inbox:writeReply to and moderate comments, reviews, and mentionsRead or send direct messages. That needs dms:read and dms:send.
dms:readRead direct message conversationsSend anything, including a reply inside a thread it can already read.
dms:sendSend direct messagesRead the conversation it is replying into. Pair it with dms:read for any real bot.
webhooks:manageManage webhook endpoints and deliveriesBe held at all by a brand-restricted key. Endpoints and deliveries span every brand.
analytics:readRead the events log, summaries, and exportsBe held at all by a brand-restricted key, for the same reason: the events log is account-wide.

Grouping causes one recurring mistake. inbox:read and inbox:write cover comments, reviews, and mentions; direct messages sit under their own pair. A reply bot handed the inbox pair alone answers comments cheerfully and stays blind to the DM queue, which nobody notices until a customer complains twice. Pull the catalog from GET /v1/keys/scopes and render it, rather than hardcoding twelve strings that will drift.

bash
# Least privilege for one client's reply bot: two dimensions at once.
curl -X POST https://api.social-api.ai/v1/keys \
  -H "Authorization: Bearer $SOCAPI_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Acme client bot",
    "scopes": ["inbox:read", "inbox:write", "dms:send"],
    "brand_ids": ["1c9f6f0e-2b3a-4c5d-8e7f-0a1b2c3d4e5f"]
  }'

# 201. raw_key is returned exactly once; the arrays are echoed back so
# your provisioning test can assert on what was actually applied.
# { "id": "b7e2...", "raw_key": "sapi_key_...",
#   "scopes": ["inbox:read", "inbox:write", "dms:send"],
#   "brand_ids": ["1c9f6f0e-2b3a-4c5d-8e7f-0a1b2c3d4e5f"] }

# That key calling POST /v1/posts:
# 403
# { "error": {
#     "code": "auth.insufficient_scope",
#     "message": "This API key does not have the required scope: posts:write",
#     "meta": { "required_scope": "posts:write" } },
#   "request_id": "a1b2c3d4" }

A vague promise that data stays separate fails the only test a multi-tenant buyer cares about, which is show me the boundary. Here it is, stated as behaviour you can assert against in an integration test rather than as a property you have to trust.

  • A brand-restricted key behaves as though the allowed brands are the only brands that exist. Reads of an out-of-scope brand, account, conversation, or interaction return the ordinary 404 (brand.not_found, account.not_found), never a 403, so the key cannot use error codes to map what it is locked out of.
  • List endpoints are filtered, never refused. Accounts, brands, posts, conversations, inbox, and mentions all return only rows inside the allowed brands, and GET /v1/brands returns only the allowed brands.
  • Posts are reached through their target accounts. A post is readable if at least one target belongs to an allowed brand; a write requires every target to belong to one, and a violation returns the same 404 account.not_found as a nonexistent account.
  • Media is the documented exception, and you should know it before you promise a client otherwise. media:read and media:write stay available to a brand-restricted key because the library is one account-level pool. Brand enforcement happens when a file is attached to a post, not when it is uploaded.
  • webhooks:manage and analytics:read are refused to any brand-restricted key, even when granted, because webhook endpoints, deliveries, the events log, and exports are not partitioned by brand today. The refusal is 403 auth.insufficient_scope with the message This operation is not available to brand-restricted API keys.
  • The whole admin tier is closed to restricted keys: creating, listing, editing, rotating, or revoking keys (including itself), editing the user profile, creating or deleting brands, and managing invites. Listing brands stays available with accounts:read. A key that could call POST /v1/keys could mint itself an unrestricted key, so the tier is denied wholesale instead of guarded per route.

A key that cannot tell an out-of-brand resource from a nonexistent one cannot map what it is locked out of. That is why the answer is 404.

Two consequences are worth designing around rather than discovering. A draft with no targets is invisible to a brand-restricted key, so a review queue built on empty drafts has to attach targets early or hold the queue behind an unrestricted service. And deleting a brand never widens a key: the stale ID stays in brand_ids and matches nothing, because pruning it to an empty array would silently flip that key back to full access.

Rotating a key without breaking the automation holding it

PATCH /v1/keys/{id} replaces the name, scopes, and brands in place and leaves the secret alone, so a live automation keeps running while you narrow what it can reach. POST /v1/keys/{id}/rotate does the opposite: same id, name, scopes, and brands, new raw_key, old secret dead immediately. Use the first to fix a permission mistake and the second when a credential may have leaked.

bash
# Narrow an over-granted key. Secret unchanged, so nothing redeploys.
curl -X PATCH https://api.social-api.ai/v1/keys/b7e2... \
  -H "Authorization: Bearer $SOCAPI_KEY" -H "Content-Type: application/json" \
  -d '{"scopes": ["inbox:read", "inbox:write"], "brand_ids": ["1c9f6f0e-..."]}'

# Leaked key. New secret, same restrictions, old one stops working now.
curl -X POST https://api.social-api.ai/v1/keys/b7e2.../rotate \
  -H "Authorization: Bearer $SOCAPI_KEY"

# Typos fail at creation, not at 3am against a live client:
#   400 validation.scope_invalid   -> names the offending value
#   400 validation.brand_invalid   -> brand missing or not yours

Both calls are admin operations, and that detail belongs in your incident runbook today rather than during the incident. A restricted key cannot rotate or revoke anything, including itself. If the leaked credential is the only one your automation holds, the fix comes from a full-access key or the dashboard, and somebody has to be awake with access to one of them.

Questions developers ask about multi-account social media APIs

What is the best API for managing multiple social media accounts?
The one whose tenancy model matches how you will be billed and audited, because that is the part you cannot refactor later. On SocialAPI.ai the unit is a brand: one client, business, or location, holding every connected account, post, and interaction underneath it. Nine platforms sit behind that grouping (Instagram, Facebook, Threads, TikTok, YouTube, X/Twitter, LinkedIn, Telegram, Google Business Profile) and plans are sized in brands rather than in seats or connected accounts. Evaluate any competitor on the same axis: ask what happens when client A's credential is used against client B, and whether the answer is a 403 or a 404.
What is a social media brand API?
A brand API is the grouping layer above connected accounts. On SocialAPI.ai a Brand is the top-level object: POST /v1/brands creates one, GET /v1/brands lists them, and every account, post, and interaction belongs to exactly one. It does two jobs at once. It meters billing, since plans are sized in brands, and it draws the isolation line, because accounts under one brand cannot see accounts under another. Note that brand creation is not idempotent: there is no external_id or idempotency key and names are not unique, so persist the returned UUID immediately.
How do scoped API keys isolate one client's data from another's?
Through two independent arrays on the key. scopes limits which actions it may perform, and brand_ids limits which brands it may see. A brand-restricted key behaves as if the allowed brands are the only ones that exist: out-of-scope reads return an ordinary 404 (brand.not_found, account.not_found) rather than a 403, list endpoints return only in-scope rows, and posts are matched through their target accounts, readable when one target is allowed and writable only when every target is. Media is the documented exception, staying an account-level pool with enforcement applied when a file is attached to a post.
What API key scopes does SocialAPI.ai support?
Twelve, in the form resource:action, grouped as Accounts (accounts:read, accounts:manage), Posts (posts:read, posts:write), Media (media:read, media:write), Inbox (inbox:read, inbox:write), Messages (dms:read, dms:send), and account-level (webhooks:manage, analytics:read). The two account-level scopes cannot be held by a brand-restricted key, because webhook endpoints, deliveries, the events log, and exports are not partitioned by brand. Fetch the live catalog from GET /v1/keys/scopes, which any valid key can call, including a restricted one, and render it rather than hardcoding the list.
Is there a social media API with a built-in approval workflow?
Not from us, and the honest version helps you evaluate faster. SocialAPI.ai ships the primitives rather than the screen: a post created with neither publish_now nor scheduled_at sits as a draft indefinitely, POST /v1/posts/validate runs the character-limit, media, and schedule checks as a dry run with per-field errors and warnings, and POST /v1/posts/{pid}/publish accepts a draft or scheduled post and is the call that spends a post credit. One caveat shapes the design: posts:write grants create, edit, publish, and delete together, so the human gate has to live in your own service rather than in the scope grant.
How do agency clients connect their accounts without sharing passwords?
With an invite link. POST /v1/invites takes a brand_id, a platform, and an expires_in_days of 1, 3, 7, 14, or 30, and returns a single-use token and URL. The client opens GET /v1/invite/{token}, a public endpoint with no auth, gets redirected into the platform's own OAuth screen, and comes back connected to your brand. A missing token answers 404 and a spent or expired one answers 410. The create call refuses with 409 if the brand already holds an account on that platform or an active invite exists for the same brand and platform pair, and DELETE /v1/invites/{id} revokes an unredeemed link.
How many client accounts can one plan hold, and what does it cost?
Plans are sized in brands, which pricing calls social profiles. The Hobby tier is free and holds 2 brands with 10 posts and 50 interactions a month. Paid tiers hold 10 at $29/mo, 50 at $109/mo, and 200 at $349/mo, with unlimited posts and interactions. At the top tier that works out to $1.75 per client brand per month. There is no per-connected-account charge on top, so an agency running five platforms per client pays the same as one running two.

Multi-tenancy is cheap to get right on day one and expensive to retrofit on day four hundred. Provision one brand per client, mint one restricted key per automation, and keep the approval step in code you own. The neighbouring pieces are covered separately: build-versus-buy arithmetic in social media API integration, the queue shape in unified social inbox API, and per-profile pricing in best Ayrshare alternative for agencies. Or start on the free tier, which holds 2 brands and is enough to prove the 404 yourself.

SocialAPI.ai product documentation cited above, all accessed 6 August 2026: SocialAPI.ai: scoped API keys guide (scope catalog, brand restrictions, error codes) · SocialAPI.ai: core concepts (Brand, Account, Post, Interaction, Capability) · SocialAPI.ai: create an API key (scopes and brand_ids fields) · SocialAPI.ai: list API key scopes (GET /v1/keys/scopes) · SocialAPI.ai: rotate an API key · SocialAPI.ai: create a brand · SocialAPI.ai: create an invite link (single-use, expiry, 409 conflicts) · SocialAPI.ai: redeem an invite link (public endpoint, 302/404/410) · SocialAPI.ai: create or schedule a post (draft, scheduled, publish_now) · SocialAPI.ai: validate post content (dry run) · SocialAPI.ai: publish a draft post now (draft or scheduled only) · SocialAPI.ai: pricing (brand limits per tier)

Get started today

Ready to unify your social interactions?

Free tier available · No credit card required · Ships with MCP server

We use essential cookies for security, and analytics cookies with your consent. Privacy Policy.