# Add Flung to your admin portal

> Page: https://docs.flung.dev/admin-portal. Index of every page: https://docs.flung.dev/llms.txt

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

| Question | Answer |
|---|---|
| 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.

| Holds | Browser | Your server | Flung |
|---|---|---|---|
| API key | Never | Yes, in an environment variable | Only its hash |
| Connect token | Yes, for up to an hour | Makes it per request | Checks its signature |
| Platform tokens | Never | Never | Yes, encrypted |
| Profile id | It may see it | Stores it beside your customer | Yes |

## 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](https://docs.flung.dev/reference/api/post-v1-api-keys.md).

Check the key and its workspace:

**curl**

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

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.me.get()
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/me', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
  },
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

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

```bash
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=
```

| Variable | Where | Value |
|---|---|---|
| `FLUNG_API_KEY` | Server only | The `flung_sk_` key. Never give it a public prefix such as `NEXT_PUBLIC_` or `VITE_`. |
| `FLUNG_BASE_URL` | Server, and passed to the panel | `https://api-preview.flung.dev` while building. Leave it empty for production. |
| `APP_ORIGIN` | Server | Your admin portal's origin, such as `https://admin.example.com`. Connect tokens work only from it. |
| `FLUNG_WEBHOOK_SECRET` | Server only | The `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.

| Request | Body | Answer |
|---|---|---|
| `POST /v1/profiles` | `{ "name": "Acme Studio", "externalId": "user_8412" }`; `name` is required, `externalId` is up to 255 characters and unique in the workspace | `201` with `{ "id": "prof_...", "name", "externalId", "createdAt", "updatedAt" }`. `409 external_id_taken` when a profile already has that `externalId`. |
| `GET /v1/profiles?externalId=user_8412` | None | `{ "data": [profile], "hasMore": false }`. Use it to find the profile again, or after a `409`. |
| `GET /v1/profiles/{id}` | None | The profile, or `404`. |
| `PATCH /v1/profiles/{id}` | `{ "name": "..." }` or `{ "externalId": "..." }`, or `null` to clear it | The profile. |
| `DELETE /v1/profiles/{id}` | None | `204`. 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**

```bash
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"
  ]
}'
```

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.connectTokens.create({
  origins: [
    'https://app.example.com',
  ],
  platforms: [
    'x',
    'linkedin',
    'bluesky',
  ],
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/connect-tokens', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    origins: [
      'https://app.example.com',
    ],
    platforms: [
      'x',
      'linkedin',
      'bluesky',
    ],
  }),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

| Field | Meaning |
|---|---|
| `origins` | Required. The pages that will use the token, as origins: https, or `http://localhost` for trying it out. |
| `profileId` | The 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. |
| `platforms` | The platform ids the token may connect. Leave it out for every platform. |
| `expiresIn` | Seconds, 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](https://docs.flung.dev/reference/connect.md).

## 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**

```bash
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"
}'
```

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.accounts.connect({
  platform: 'linkedin',
  redirectUrl: 'https://app.example.com/settings/social',
  handle: 'alice.bsky.social',
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/connect/linkedin', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    redirectUrl: 'https://app.example.com/settings/social',
    handle: 'alice.bsky.social',
  }),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

Then read the session until it is no longer `pending`:

**curl**

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

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.connectSessions.get({
  id: 'cs_aaaaaaaaaaaaaaaaaaaaaaaa',
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/connect-sessions/cs_aaaaaaaaaaaaaaaaaaaaaaaa', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
  },
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

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 status | Meaning |
|---|---|
| `pending` | The person has not finished on the platform yet. |
| `connected` | Done. `accountIds` lists the accounts stored. Facebook stores one per Page, and LinkedIn can store the organisations the person administers too. |
| `failed` | `error.code` says why: `access_denied`, `wrong_account`, or the platform's reason. |
| `expired` | The 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**

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

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.accounts.list({
  reconnectBefore: '2026-11-30T00:00:00Z',
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/accounts?reconnectBefore=2026-11-30T00%3A00%3A00Z', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
  },
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

Read one account:

**curl**

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

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.accounts.get({
  id: 'acct_aaaaaaaaaaaaaaaaaaaaaaaa',
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/accounts/acct_aaaaaaaaaaaaaaaaaaaaaaaa', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
  },
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

| Field | What 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. |
| `renewal` | `automatic`: Flung renews the token itself. `manual`: only a reconnect renews it. `not_needed`: it does not expire. |
| `reconnectBy` | The last moment the account works without a reconnect, or null. Flung sends `account.reconnect_required_soon` a week before. |
| `profileId` | The 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**

```bash
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"
}'
```

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.accounts.reconnect({
  id: 'acct_aaaaaaaaaaaaaaaaaaaaaaaa',
  redirectUrl: 'https://app.example.com/settings/social',
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/accounts/acct_aaaaaaaaaaaaaaaaaaaaaaaa/reconnect', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    redirectUrl: 'https://app.example.com/settings/social',
  }),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

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

**curl**

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

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.accounts.disconnect({
  id: 'acct_aaaaaaaaaaaaaaaaaaaaaaaa',
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/accounts/acct_aaaaaaaaaaaaaaaaaaaaaaaa', {
  method: 'DELETE',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
  },
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

## 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**

```bash
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"
}'
```

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.media.import({
  url: 'https://example.com/launch.jpg',
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/media/import', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    url: 'https://example.com/launch.jpg',
  }),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

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**

```bash
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"
  }
}'
```

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.posts.create({
  content: 'Launching today.',
  accountIds: [
    'acct_aaaaaaaaaaaaaaaaaaaaaaaa',
  ],
  platformOverrides: {
    x: {
      content: 'Launching today. #launch',
    },
  },
  variables: {
    product: 'Flung',
  },
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/posts', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    content: 'Launching today.',
    accountIds: [
      'acct_aaaaaaaaaaaaaaaaaaaaaaaa',
    ],
    platformOverrides: {
      x: {
        content: 'Launching today. #launch',
      },
    },
    variables: {
      product: 'Flung',
    },
  }),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

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**

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

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.posts.get({
  id: 'post_aaaaaaaaaaaaaaaaaaaaaaaa',
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/posts/post_aaaaaaaaaaaaaaaaaaaaaaaa', {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
  },
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

| To | Use |
|---|---|
| Check a post fits before you send it | [List platforms](https://docs.flung.dev/reference/api/get-v1-platforms.md): each platform's limits on text, images and video. A post a platform cannot carry is refused when you create it. |
| Let TikTok creators choose | [Read what a post may choose](https://docs.flung.dev/reference/api/get-v1-accounts-id-posting-options.md), and show the options before each TikTok post. |
| Pick a Pinterest board | [List Pinterest boards](https://docs.flung.dev/reference/api/get-v1-accounts-id-boards.md). |
| Change or cancel a scheduled post | [Change a scheduled post](https://docs.flung.dev/reference/api/patch-v1-posts-id.md), [Cancel a scheduled post](https://docs.flung.dev/reference/api/post-v1-posts-id-cancel.md). |
| Try the failed accounts again | [Retry the failed accounts of a post](https://docs.flung.dev/reference/api/post-v1-posts-id-retry.md). |

## 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**

```bash
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"
  ]
}'
```

**TypeScript**

```ts
import { Flung } from '@flung.dev/sdk'

const flung = new Flung({ apiKey: process.env.FLUNG_API_KEY! })
const result = await flung.webhookEndpoints.create({
  url: 'https://example.com/webhooks/flung',
  events: [
    'post.published',
    'post.failed',
  ],
})
```

**fetch**

```js
const response = await fetch('https://api.flung.dev/v1/webhook-endpoints', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.FLUNG_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    url: 'https://example.com/webhooks/flung',
    events: [
      'post.published',
      'post.failed',
    ],
  }),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const result = await response.json()
```

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.

| Code | Status | What your portal should do |
|---|---|---|
| `plan_limit_reached` | 403 | Say 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_limited` | 429 | Wait for the seconds in `Retry-After`. The SDK does. |
| `account_needs_reconnect` | 422 | Ask the person to reconnect the account. |
| `validation_failed` | 422 | Show `details`: it names each field, including accounts outside the profile. |
| `unknown_accounts`, `unknown_media` | 422 | An id is not in this workspace. |
| `media_not_ready` | 422 | Wait until the file is `ready`. |
| `text_too_long`, `media_required`, `unsupported_media`, `media_too_large` | 422 | Show the platform's limit from `details`. |
| `forbidden` | 403 | A connect token used outside its seven calls, its origin or its profile. |
| `unauthorized` | 401 | The 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.

| Limit | Free | Pro | Business |
|---|---|---|---|
| Connected accounts | 3 | 15 | 100 |
| Posts a month | 30 | 1,000 | 10,000 |
| Profiles | 1 | 10 | No limit |
| Requests a minute, per key | 60 | 300 | 600 |
| Stored files | 1 GB | 25 GB | 250 GB |
| Webhook endpoints | 1 | 10 | 50 |

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

| Service | Address |
|---|---|
| REST API | `https://api.flung.dev` |
| REST API, preview | `https://api-preview.flung.dev` |
| MCP server | `https://mcp.flung.dev` |
| Brand files | `https://assets.flung.dev` |
| These docs | `https://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

| Check | How to test it |
|---|---|
| The key stays on the server | Search 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-in | Call it signed out: it answers `401`. |
| One customer, one profile | Sign 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 matches | The panel loads accounts on your real portal address. A wrong `APP_ORIGIN` makes the API refuse the panel's calls. |
| Preview end to end | On `https://api-preview.flung.dev`: connect an account, see it in the panel, post text, post an image, see `post.published` arrive and verify. |
| Reconnect | An account with `needs_reconnect` shows Reconnect, and reconnecting it turns it `active`. |
| Disconnect | Disconnecting asks first, the account turns `disconnected`, and your post form hides it. |
| Failures | A post too long for a platform is refused with `text_too_long`, and your portal shows why. |
| Limits | On Free, a fourth account answers `plan_limit_reached`, and your portal shows the upgrade link. |
| Retries | A repeated post request with the same `Idempotency-Key` makes one post. |
| Webhooks | A wrong signature is refused, and a repeated event id is handled once. |

## Where next

| To | Read |
|---|---|
| See every attribute and event of the panel | [Connect panel](https://docs.flung.dev/reference/connect.md) |
| Learn the three credentials | [Authentication](https://docs.flung.dev/authentication.md) |
| Schedule, change and retry posts | [Publish and schedule posts](https://docs.flung.dev/posting.md) |
| Handle every failure | [Errors](https://docs.flung.dev/reference/api/errors.md) |
| See every endpoint | [API reference](https://docs.flung.dev/reference/api.md) |
