Skip to content
Connect an agent

Build

Add Flung to your admin portal

A step-by-step build, written for a coding agent: let the people who use your admin portal connect their social accounts and post to them through Flung, with your API key kept on your server.

TL;DR

QuestionAnswer
What will the portal do?Show each of your customers a panel to connect, reconnect and disconnect their social accounts, post to those accounts with text, images and video, and show what happened on each platform.
What are the parts?Your server, which holds the API key, keeps one Flung profile per customer and makes connect tokens. The browser, which shows <flung-connect> with a token. And a webhook route, where Flung says what was published.
What do I need first?A full-access Flung API key (flung_sk_...) for the environment you build against. While Flung is in private beta, the Flung team issues it.
Which API do I build against?Preview, https://api-preview.flung.dev, until you go live. Then production, https://api.flung.dev, with a production key.
What must never happen?The API key reaching the browser, or one customer seeing or posting to another customer's accounts.

1. How the parts fit

  1. A person signs in to your admin portal, with your own sign-in.
  2. Your server finds that person's customer, and the Flung profile it made for that customer, by your own customer id.
  3. The page asks your server for a connect token. Your server checks the session, then asks Flung for a token for that profile and your portal's origin, and hands the token to the page.
  4. The page shows the connect panel with the token. The panel calls Flung directly, but the token reaches only seven calls (list, read, connect, reconnect and disconnect accounts, read a connect, list platforms), only from your origin, only for that profile, and only until it expires.
  5. To post, the page sends the post to your server. Your server checks the accounts belong to the customer, and calls Flung with the API key and the profile.
  6. Flung publishes, and sends signed webhook events to your server as each account succeeds or fails.
HoldsBrowserYour serverFlung
API keyNeverYes, in an environment variableOnly its hash
Connect tokenYes, for up to an hourMakes it per requestChecks its signature
Platform tokensNeverNeverYes, encrypted
Profile idIt may see itStores it beside your customerYes

2. Get an API key

During the private beta, ask the Flung team for a full-access key for your workspace. They issue it from Flung's staff console and it is shown once. The portal at app.flung.dev signs you in and shows your workspaces, but it does not issue keys yet.

Keys belong to one environment: a preview key works only on https://api-preview.flung.dev, and a production key only on https://api.flung.dev. With one full-access key you can make more, such as one per server, with Create an API key.

Check the key and its workspace:

curl
curl -X GET 'https://api.flung.dev/v1/me' \  --header "Authorization: Bearer $FLUNG_API_KEY"

The samples on this page call production. On preview, send the same requests to https://api-preview.flung.dev, or give the SDK baseUrl: 'https://api-preview.flung.dev'.

3. Install and configure

npm
npm install @flung.dev/sdk @flung.dev/connect

@flung.dev/sdk runs on your server. @flung.dev/connect runs in the browser and holds no secret.

.env.local
bash
# A full-access Flung API key (flung_sk_...). Server only: never prefix it with NEXT_PUBLIC_.FLUNG_API_KEY=
# Optional. The API: https://api.flung.dev (the default) or https://api-preview.flung.dev.FLUNG_BASE_URL=
# The origin this app is served from, which connect tokens are made for. http://localhost:3000 when left out.APP_ORIGIN=
VariableWhereValue
FLUNG_API_KEYServer onlyThe flung_sk_ key. Never give it a public prefix such as NEXT_PUBLIC_ or VITE_.
FLUNG_BASE_URLServer, and passed to the panelhttps://api-preview.flung.dev while building. Leave it empty for production.
APP_ORIGINServerYour admin portal's origin, such as https://admin.example.com. Connect tokens work only from it.
FLUNG_WEBHOOK_SECRETServer onlyThe whsec_ secret from step 9.

4. Keep one profile per customer

A profile keeps one customer's accounts apart from the rest of your workspace. Make one the first time a customer opens the page, with your own customer id as its externalId, and store its id beside your customer.

RequestBodyAnswer
POST /v1/profiles{ "name": "Acme Studio", "externalId": "user_8412" }; name is required, externalId is up to 255 characters and unique in the workspace201 with { "id": "prof_...", "name", "externalId", "createdAt", "updatedAt" }. 409 external_id_taken when a profile already has that externalId.
GET /v1/profiles?externalId=user_8412None{ "data": [profile], "hasMore": false }. Use it to find the profile again, or after a 409.
GET /v1/profiles/{id}NoneThe profile, or 404.
PATCH /v1/profiles/{id}{ "name": "..." } or { "externalId": "..." }, or null to clear itThe profile.
DELETE /v1/profiles/{id}None204. For a customer who leaves: disconnects every account in the profile, cancels its scheduled posts, and drops connects still waiting. It cannot be undone.

Send each with Authorization: Bearer $FLUNG_API_KEY, and an Idempotency-Key on the POST. The SDK version these docs use has no profile methods yet, so call these with fetch from your server. A workspace may have 1 profile on Free, 10 on Pro and any number on Business; past that, POST /v1/profiles answers 403 plan_limit_reached.

5. Make connect tokens on your server

Add a route the page calls for a token. This one, from a working Next.js app, makes a token for APP_ORIGIN for fifteen minutes:

app/api/connect-token/route.ts
ts
import { Flung, FlungError } from '@flung.dev/sdk'
/** * `POST /api/connect-token`: a short-lived connect token for this app's own page. The API key stays here on the server; * the token it makes can only list, connect, reconnect and disconnect accounts, from `APP_ORIGIN`, for fifteen minutes. * * In a real app, check who is signed in first, and pass their profile as `profileId` so each person sees only theirs. */export async function POST(): Promise<Response> {  const apiKey = process.env.FLUNG_API_KEY  if (apiKey === undefined || apiKey === '') return Response.json({ error: 'Set FLUNG_API_KEY in .env.local.' }, { status: 500 })  const flung = new Flung({ apiKey, ...(process.env.FLUNG_BASE_URL ? { baseUrl: process.env.FLUNG_BASE_URL } : {}) })  try {    const { token, expiresAt } = await flung.connectTokens.create({ origins: [process.env.APP_ORIGIN || 'http://localhost:3000'], expiresIn: 900 })    return Response.json({ token, expiresAt }, { headers: { 'cache-control': 'no-store' } })  } catch (error) {    const status = error instanceof FlungError ? (error.status ?? 502) : 502    return Response.json({ error: error instanceof Error ? error.message : 'Could not make a connect token.' }, { status })  }}

In your admin portal, change it in three ways:

  1. At its top, check your own session. Answer 401 when nobody is signed in.
  2. Find the customer's profile from step 4, and add profileId: profile.id to the call. The token then sees and connects only that profile's accounts, and every account connected with it joins the profile.
  3. Add platforms to offer only some platforms, if you want.

The whole request, as the API takes it:

curl
curl -X POST 'https://api.flung.dev/v1/connect-tokens' \  --header "Authorization: Bearer $FLUNG_API_KEY" \  --header 'Content-Type: application/json' \  --header "Idempotency-Key: $(uuidgen)" \  --data '{  "origins": [    "https://app.example.com"  ],  "platforms": [    "x",    "linkedin",    "bluesky"  ]}'
FieldMeaning
originsRequired. The pages that will use the token, as origins: https, or http://localhost for trying it out.
profileIdThe customer's profile. Leave it out and the token sees every account in the workspace, which an admin portal for many customers must never do.
platformsThe platform ids the token may connect. Leave it out for every platform.
expiresInSeconds, from 60 to 86400. One hour by default.

The answer's token starts flung_ct_. Return it with cache-control: no-store.

6. Show the connect panel

The panel asks your route for a token, and again when one expires:

app/accounts.tsx
tsx
'use client'
import { useCallback, useState } from 'react'
import { FlungConnect } from '@flung.dev/connect/react'
/** Asks this app's server for a connect token; the panel calls it again when a token expires. */async function connectToken(): Promise<string> {  const response = await fetch('/api/connect-token', { method: 'POST' })  const body = (await response.json()) as { token?: string; error?: string }  if (!response.ok || body.token === undefined) throw new Error(body.error ?? 'Could not make a connect token.')  return body.token}
export function Accounts({ baseUrl }: { baseUrl: string | undefined }) {  const [events, setEvents] = useState<string[]>([])  const log = useCallback((line: string) => setEvents((previous) => [line, ...previous].slice(0, 5)), [])  return (    <>      <FlungConnect        token={connectToken}        baseUrl={baseUrl}        onConnect={({ accounts }) => log(`Connected ${accounts.map((account) => account.username ?? account.id).join(', ')}`)}        onDisconnect={({ account }) => log(`Disconnected ${account.username ?? account.id}`)}        onError={({ code, message }) => log(`${code}: ${message}`)}      />      {events.length > 0 && (        <ul className="events" aria-label="What happened">          {events.map((line, index) => (            <li key={`${index}-${line}`}>{line}</li>          ))}        </ul>      )}    </>  )}

Pass baseUrl from FLUNG_BASE_URL, so the panel calls the same API your server made the token on. A preview token sent to production is refused. The panel lists the profile's accounts, offers the platforms, opens each platform's sign-in in a small window, and asks before it disconnects. onConnect, onDisconnect and onError tell your page what happened; every property, event, style hook and word is in the connect panel reference.

7. Or build your own connect screen

The panel is optional. The same calls work from your own page with the connect token, or from your server with the API key and a profileId in the body. Start a connect, send the person to its authorizationUrl, and give redirectUrl to bring them back to your portal:

curl
curl -X POST 'https://api.flung.dev/v1/connect/linkedin' \  --header "Authorization: Bearer $FLUNG_API_KEY" \  --header 'Content-Type: application/json' \  --header "Idempotency-Key: $(uuidgen)" \  --data '{  "redirectUrl": "https://app.example.com/settings/social",  "handle": "alice.bsky.social"}'

Then read the session until it is no longer pending:

curl
curl -X GET 'https://api.flung.dev/v1/connect-sessions/cs_aaaaaaaaaaaaaaaaaaaaaaaa' \  --header "Authorization: Bearer $FLUNG_API_KEY"

On the server the SDK waits for you:

ts
const session = await flung.accounts.connect({ platform: 'linkedin' })console.warn(`Open ${session.authorizationUrl}`)const done = await flung.waitForConnection(session.id)
Session statusMeaning
pendingThe person has not finished on the platform yet.
connectedDone. accountIds lists the accounts stored. Facebook stores one per Page, and LinkedIn can store the organisations the person administers too.
failederror.code says why: access_denied, wrong_account, or the platform's reason.
expiredThe person did not finish by expiresAt. Start again.

For Mastodon, handle is required: the person's server, such as mastodon.social. For Bluesky it is optional: the handle signing in. POST /v1/connect/{platform} answers 403 plan_limit_reached when the workspace is at its plan's account limit.

8. List accounts, and keep them working

List a customer's accounts on your server with GET /v1/accounts?profileId=prof_.... The profileId filter keeps the list to one customer; the panel's token does this by itself. reconnectBefore finds the accounts that need a person soon:

curl
curl -X GET 'https://api.flung.dev/v1/accounts?reconnectBefore=2026-11-30T00%3A00%3A00Z' \  --header "Authorization: Bearer $FLUNG_API_KEY"

Read one account:

curl
curl -X GET 'https://api.flung.dev/v1/accounts/acct_aaaaaaaaaaaaaaaaaaaaaaaa' \  --header "Authorization: Bearer $FLUNG_API_KEY"
FieldWhat to do with it
status: "active"Post to it.
status: "needs_reconnect"The platform refused the token. Show a Reconnect button; posts to it fail with account_needs_reconnect.
status: "disconnected"Someone disconnected it. It stays listed so past posts keep their results. Hide it from your post form.
renewalautomatic: Flung renews the token itself. manual: only a reconnect renews it. not_needed: it does not expire.
reconnectByThe last moment the account works without a reconnect, or null. Flung sends account.reconnect_required_soon a week before.
profileIdThe customer it belongs to. Check it on your server before you post.

The panel shows Reconnect by itself. To reconnect from your own screen, start one for the account and send the person to its authorizationUrl, as in step 7. It must come back as the same platform user:

curl
curl -X POST 'https://api.flung.dev/v1/accounts/acct_aaaaaaaaaaaaaaaaaaaaaaaa/reconnect' \  --header "Authorization: Bearer $FLUNG_API_KEY" \  --header 'Content-Type: application/json' \  --header "Idempotency-Key: $(uuidgen)" \  --data '{  "redirectUrl": "https://app.example.com/settings/social"}'

To disconnect from your own screen, which deletes the stored tokens:

curl
curl -X DELETE 'https://api.flung.dev/v1/accounts/acct_aaaaaaaaaaaaaaaaaaaaaaaa' \  --header "Authorization: Bearer $FLUNG_API_KEY"

9. Post with images and video

Posting always runs on your server with the API key. The page sends your server the text, the files and the account ids; your server checks the person may post for that customer, checks every account id is in the customer's profile, and calls Flung.

Upload a file from your server, and wait for it to be ready:

ts
const image = await flung.uploadMedia(await readFile('launch.png'), { contentType: 'image/png', filename: 'launch.png' })await flung.posts.create({ content: 'Launching today.', accountIds, mediaIds: [image.id] })

Or have Flung fetch it from a public https address:

curl
curl -X POST 'https://api.flung.dev/v1/media/import' \  --header "Authorization: Bearer $FLUNG_API_KEY" \  --header 'Content-Type: application/json' \  --header "Idempotency-Key: $(uuidgen)" \  --data '{  "url": "https://example.com/launch.jpg"}'

Then post, with the files' ids in mediaIds and the customer's profile in profileId. Flung refuses the post with validation_failed when an account is not in that profile, so one customer can never post to another's accounts. Leave out publishAt to publish now:

curl
curl -X POST 'https://api.flung.dev/v1/posts' \  --header "Authorization: Bearer $FLUNG_API_KEY" \  --header 'Content-Type: application/json' \  --header "Idempotency-Key: $(uuidgen)" \  --data '{  "content": "Launching today.",  "accountIds": [    "acct_aaaaaaaaaaaaaaaaaaaaaaaa"  ],  "platformOverrides": {    "x": {      "content": "Launching today. #launch"    }  },  "variables": {    "product": "Flung"  }}'

The answer has a target per account. Each is pending, then publishing, then published with the platform's link, or failed with a reason. Read the post again to see them:

curl
curl -X GET 'https://api.flung.dev/v1/posts/post_aaaaaaaaaaaaaaaaaaaaaaaa' \  --header "Authorization: Bearer $FLUNG_API_KEY"
ToUse
Check a post fits before you send itList platforms: each platform's limits on text, images and video. A post a platform cannot carry is refused when you create it.
Let TikTok creators chooseRead what a post may choose, and show the options before each TikTok post.
Pick a Pinterest boardList Pinterest boards.
Change or cancel a scheduled postChange a scheduled post, Cancel a scheduled post.
Try the failed accounts againRetry the failed accounts of a post.

10. Hear what happened

Register an https endpoint once, from a script or your server, and keep the whsec_ secret it returns in FLUNG_WEBHOOK_SECRET:

curl
curl -X POST 'https://api.flung.dev/v1/webhook-endpoints' \  --header "Authorization: Bearer $FLUNG_API_KEY" \  --header 'Content-Type: application/json' \  --header "Idempotency-Key: $(uuidgen)" \  --data '{  "url": "https://example.com/webhooks/flung",  "events": [    "post.published",    "post.failed"  ]}'

In your webhook route, read the raw body as text and verify it before you trust it:

ts
export async function POST(request: Request): Promise<Response> {  const body = await request.text()  const event = await verifyWebhook({ body, headers: request.headers, secret: process.env.FLUNG_WEBHOOK_SECRET! })  if (event.type === 'post.failed') console.warn(`Post ${event.data.post.id} failed`)  return new Response(null, { status: 204 })}
Event
post.published
post.partially_published
post.failed
target.published
target.failed
account.connected
account.disconnected
account.token_refresh_failed
account.reconnect_required_soon
inbox.received

Every event has an id, a type, a createdAt time and data with the post or the account. Flung retries an event your endpoint did not accept, so store the ids you handled and skip a repeat. Update your own record of the post on target.published, target.failed, post.published, post.partially_published and post.failed, and show a reconnect prompt on account.token_refresh_failed and account.reconnect_required_soon.

11. Errors, limits and plans

Every failure is { "error": { "code", "message", "details" } }. Branch on code; the SDK throws FlungError with the same fields.

CodeStatusWhat your portal should do
plan_limit_reached403Say the workspace is at its plan's limit. details has limit (accounts, postsPerMonth, profiles, mediaBytes or webhookEndpoints), plan, max, used and upgradeUrl, the workspace's Plan page in the Flung portal.
rate_limited429Wait for the seconds in Retry-After. The SDK does.
account_needs_reconnect422Ask the person to reconnect the account.
validation_failed422Show details: it names each field, including accounts outside the profile.
unknown_accounts, unknown_media422An id is not in this workspace.
media_not_ready422Wait until the file is ready.
text_too_long, media_required, unsupported_media, media_too_large422Show the platform's limit from details.
forbidden403A connect token used outside its seven calls, its origin or its profile.
unauthorized401The key is wrong, revoked, or from the other environment.

Every list returns a page at a time with hasMore; pass the last id as startingAfter. Every POST takes an Idempotency-Key, so a retry with the same key gets the first answer instead of a second post. The SDK sends one for you.

LimitFreeProBusiness
Connected accounts315100
Posts a month301,00010,000
Profiles110No limit
Requests a minute, per key60300600
Stored files1 GB25 GB250 GB
Webhook endpoints11050

Pro is 19 US dollars a month and Business 59, bought by a workspace owner on the workspace's Plan page at app.flung.dev.

12. Preview and production

ServiceAddress
REST APIhttps://api.flung.dev
REST API, previewhttps://api-preview.flung.dev
MCP serverhttps://mcp.flung.dev
Brand fileshttps://assets.flung.dev
These docshttps://docs.flung.dev

Build and test on preview with a preview key, then switch FLUNG_API_KEY and FLUNG_BASE_URL together for production. Accounts, profiles, posts and webhooks do not move between them: connect the accounts again on production, and register the webhook endpoint there too.

Checklist before you finish

CheckHow to test it
The key stays on the serverSearch the built browser code for flung_sk_: there is none. The key is in no public environment variable.
The token route is behind your sign-inCall it signed out: it answers 401.
One customer, one profileSign in as two customers. Each panel lists only its own accounts, and a post with the other customer's account id is refused.
The origin matchesThe panel loads accounts on your real portal address. A wrong APP_ORIGIN makes the API refuse the panel's calls.
Preview end to endOn https://api-preview.flung.dev: connect an account, see it in the panel, post text, post an image, see post.published arrive and verify.
ReconnectAn account with needs_reconnect shows Reconnect, and reconnecting it turns it active.
DisconnectDisconnecting asks first, the account turns disconnected, and your post form hides it.
FailuresA post too long for a platform is refused with text_too_long, and your portal shows why.
LimitsOn Free, a fourth account answers plan_limit_reached, and your portal shows the upgrade link.
RetriesA repeated post request with the same Idempotency-Key makes one post.
WebhooksA wrong signature is refused, and a repeated event id is handled once.

Where next

ToRead
See every attribute and event of the panelConnect panel
Learn the three credentialsAuthentication
Schedule, change and retry postsPublish and schedule posts
Handle every failureErrors
See every endpointAPI reference