# Connect your first social account (/docs/api/guides/connect-social-accounts)



## What you'll build [#what-youll-build]

A working OAuth-handoff flow: your end-customer clicks **Connect TikTok** in your UI, lands on TikTok's consent screen on `tiktok.com`, finishes, gets redirected back into your app, and a fresh `socialAccountId` shows up against their project — ready to schedule posts against.

This guide is the end-to-end glue: the button, the server endpoint that mints the authorize URL, the `/oauth/return` handler that parses Layers' failure codes, the poll loop, and the post-connect display. Every snippet is paste-able. The running example is **Acme Coffee** connecting their TikTok account from inside Quinn's Coffee CRM.

## Before you start [#before-you-start]

You need three things wired before the first call:

1. **A live API key with `social:write` and `social:read`.** Mint one from the [dashboard](https://app.layers.com/account-settings/api-keys) or your account manager. Treat it like a Stripe secret key — server-side only, never shipped to the browser.
2. **Your return URL host on the key's allowlist.** Layers checks the `returnUrl` you pass against an explicit allowlist of hosts on the API key. Allowlist updates are not self-service today — message your Layers account manager with the host(s) you want allowed (e.g. `app.quinns-crm.com`, `staging.quinns-crm.com`, `localhost` for local dev). Updates take effect within minutes; you don't need to re-mint the key.
3. **A project in your org to attach the account to.** Either create one in the dashboard or via [`POST /v1/projects`](/docs/api/reference/projects/create-project). The `projectId` is what scopes the OAuth flow.

<Callout type="info">
  TikTok and Instagram are the connectable platforms today. Instagram requires a Business or Creator account on the end-user side (Personal accounts can complete consent but can't publish). LinkedIn and YouTube are not on a committed timeline.
</Callout>

## How the handshake works (in 30 seconds) [#how-the-handshake-works-in-30-seconds]

1. Your server calls Layers to mint a Layers-hosted authorize URL with an opaque `state` token.
2. Your UI sends the end-customer to that URL (top-level redirect — it can't be framed).
3. They consent on `tiktok.com` / `instagram.com`. Both the **success** path and the **failure** path redirect them back to a `returnUrl` you supplied — Layers never re-hosts the consent UI.
4. Your `returnUrl` handler either sees no extra query params (success) or `?layers_oauth_error=<code>` (failure).
5. Your server polls Layers' status endpoint with the same `state` token until it flips to `completed`, at which point you get the new `socialAccountId` back.

You only need to handle these two routes: `POST` to mint the URL, and a `GET /oauth/return` callback. Everything else is server-side bookkeeping.

<Steps>
  <Step>
    ## Add the "Connect TikTok" button [#add-the-connect-tiktok-button]

    In your UI, point a button at a server route you own. The browser never sees the Layers API key.

    ```tsx title="ConnectTikTokButton.tsx"
    export function ConnectTikTokButton({ projectId }: { projectId: string }) {
      return (
        <form action="/connect/tiktok/start" method="POST">
          <input type="hidden" name="projectId" value={projectId} />
          <button type="submit">Connect TikTok</button>
        </form>
      );
    }
    ```
  </Step>

  <Step>
    ## Mint the authorize URL on your server [#mint-the-authorize-url-on-your-server]

    <Endpoint method="POST" path="/v1/projects/:projectId/social/oauth-url" scope="social:write" phase="1" />

    Your `/connect/tiktok/start` handler hits Layers, stashes the returned `state` in the user's session (you'll need it again in two places), and 302s the browser to `authorizeUrl`.

    <Tabs items="['TypeScript (Next.js)', 'TypeScript (Express)', 'Python (Flask)']">
      <Tab value="TypeScript (Next.js)">
        ```ts title="app/connect/tiktok/start/route.ts"
        import { NextRequest, NextResponse } from "next/server";
        import { randomUUID } from "node:crypto";
        import { getSession } from "@/lib/session"; // your session abstraction

        export async function POST(req: NextRequest) {
          const form = await req.formData();
          const projectId = String(form.get("projectId"));
          const session = await getSession(req);

          const res = await fetch(
            `https://api.layers.com/v1/projects/${projectId}/social/oauth-url`,
            {
              method: "POST",
              headers: {
                Authorization: `Bearer ${process.env.LAYERS_API_KEY!}`,
                "Idempotency-Key": randomUUID(),
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                platform: "tiktok",
                returnUrl: `${process.env.APP_BASE_URL}/connect/tiktok/return`,
                usageNote: "Connecting Acme Coffee's TikTok",
              }),
            },
          );

          if (!res.ok) {
            const err = await res.json();
            // VALIDATION (422), RETURN_URL_NOT_ALLOWED (403), or FORBIDDEN_SCOPE (403).
            // See https://docs.layers.com/docs/api/operational/errors
            console.error("layers oauth-url failed", err);
            return NextResponse.redirect(`${process.env.APP_BASE_URL}/connect/failed`);
          }

          const { authorizeUrl, state } = await res.json();

          // Persist `state` server-side so the /return handler and the poll loop can
          // both look it up. Keyed by session, not by cookie — see "State token
          // handling" on the oauth-url reference page.
          await session.set("pendingOAuth", { state, projectId, platform: "tiktok" });

          return NextResponse.redirect(authorizeUrl);
        }
        ```
      </Tab>

      <Tab value="TypeScript (Express)">
        ```ts title="routes/connect-tiktok.ts"
        import express from "express";
        import { randomUUID } from "node:crypto";

        export const connectTikTok = express.Router();

        connectTikTok.post("/connect/tiktok/start", async (req, res) => {
          const { projectId } = req.body;

          const r = await fetch(
            `https://api.layers.com/v1/projects/${projectId}/social/oauth-url`,
            {
              method: "POST",
              headers: {
                Authorization: `Bearer ${process.env.LAYERS_API_KEY!}`,
                "Idempotency-Key": randomUUID(),
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                platform: "tiktok",
                returnUrl: `${process.env.APP_BASE_URL}/connect/tiktok/return`,
                usageNote: "Connecting Acme Coffee's TikTok",
              }),
            },
          );

          if (!r.ok) {
            console.error("layers oauth-url failed", await r.json());
            return res.redirect("/connect/failed");
          }

          const { authorizeUrl, state } = await r.json();
          // Express `req.session` from express-session, cookie-session, or your store.
          req.session.pendingOAuth = { state, projectId, platform: "tiktok" };
          res.redirect(authorizeUrl);
        });
        ```
      </Tab>

      <Tab value="Python (Flask)">
        ```python title="connect_tiktok.py"
        import os
        import uuid
        import requests
        from flask import Blueprint, redirect, request, session

        connect_tiktok_bp = Blueprint("connect_tiktok", __name__)

        @connect_tiktok_bp.post("/connect/tiktok/start")
        def start():
            project_id = request.form["projectId"]
            r = requests.post(
                f"https://api.layers.com/v1/projects/{project_id}/social/oauth-url",
                headers={
                    "Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}",
                    "Idempotency-Key": str(uuid.uuid4()),
                    "Content-Type": "application/json",
                },
                json={
                    "platform": "tiktok",
                    "returnUrl": f"{os.environ['APP_BASE_URL']}/connect/tiktok/return",
                    "usageNote": "Connecting Acme Coffee's TikTok",
                },
                timeout=10,
            )
            if not r.ok:
                # See https://docs.layers.com/docs/api/operational/errors
                return redirect("/connect/failed")

            body = r.json()
            # Server-side session; do NOT put state in a cookie the user can read.
            session["pending_oauth"] = {
                "state": body["state"],
                "project_id": project_id,
                "platform": "tiktok",
            }
            return redirect(body["authorizeUrl"])
        ```
      </Tab>
    </Tabs>

    <Response status="200">
      ```json
      {
        "authorizeUrl": "https://www.tiktok.com/v2/auth/authorize?client_key=...&state=st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
        "state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
        "expiresAt": "2026-04-18T19:12:11Z"
      }
      ```
    </Response>

    <Callout type="warn">
      The authorize URL lives on `www.tiktok.com` / `www.instagram.com` and **can't be framed** — both platforms block iframing by ToS. Use a top-level redirect, not a popup-in-iframe.
    </Callout>
  </Step>

  <Step>
    ## Implement the `/oauth/return` handler [#implement-the-oauthreturn-handler]

    After consent, the end-customer's browser lands back at the `returnUrl` you passed. Two cases your handler must cover:

    * **Success:** no extra query params. The OAuth flow finished; the social account row exists on Layers' side. Your job is to read the stored `state`, kick off the status poll, and route the user to a "Connecting…" UI.
    * **Failure:** Layers appends `?layers_oauth_error=<code>`. Branch on the code — most are user-fixable (cancelled consent → "Try again"), a few are Layers-side bugs (`persistence_error` → support ticket).

    Codes you'll see on the failure path (full table on [`oauth-url`](/docs/api/reference/social-accounts/oauth-url)):

    | `layers_oauth_error` | User-visible action                                                         |
    | -------------------- | --------------------------------------------------------------------------- |
    | `state_expired`      | "This connect link expired. Try again."                                     |
    | `platform_denied`    | "You cancelled the connection on TikTok. Try again."                        |
    | `missing_code`       | "Hit a snag with TikTok. Try again."                                        |
    | `exchange_failed`    | "Couldn't finish connecting. Try again, or contact support if it persists." |
    | `persistence_error`  | "Something went wrong on our side. Contact support."                        |
    | `platform_mismatch`  | (Should never happen with normal use. Mint a fresh URL.)                    |
    | `state_terminal`     | (You replayed an already-failed state. Mint a fresh URL.)                   |

    <Tabs items="['TypeScript (Next.js)', 'TypeScript (Express)', 'Python (Flask)']">
      <Tab value="TypeScript (Next.js)">
        ```ts title="app/connect/tiktok/return/route.ts"
        import { NextRequest, NextResponse } from "next/server";
        import { getSession } from "@/lib/session";

        export async function GET(req: NextRequest) {
          const session = await getSession(req);
          const pending = await session.get("pendingOAuth");
          if (!pending) return NextResponse.redirect("/connect/failed?reason=no_session");

          const error = req.nextUrl.searchParams.get("layers_oauth_error");
          if (error) {
            await session.delete("pendingOAuth");
            return NextResponse.redirect(`/connect/failed?reason=${error}`);
          }

          // Success path — Layers has persisted the account. Send the user to a
          // "Connecting…" UI; that page (or a background job) polls oauth-status.
          return NextResponse.redirect(`/connect/connecting?state=${pending.state}`);
        }
        ```
      </Tab>

      <Tab value="TypeScript (Express)">
        ```ts title="routes/connect-tiktok-return.ts"
        connectTikTok.get("/connect/tiktok/return", (req, res) => {
          const pending = req.session.pendingOAuth;
          if (!pending) return res.redirect("/connect/failed?reason=no_session");

          const error = req.query.layers_oauth_error as string | undefined;
          if (error) {
            delete req.session.pendingOAuth;
            return res.redirect(`/connect/failed?reason=${error}`);
          }

          res.redirect(`/connect/connecting?state=${pending.state}`);
        });
        ```
      </Tab>

      <Tab value="Python (Flask)">
        ```python title="connect_tiktok_return.py"
        @connect_tiktok_bp.get("/connect/tiktok/return")
        def callback():
            pending = session.get("pending_oauth")
            if not pending:
                return redirect("/connect/failed?reason=no_session")

            error = request.args.get("layers_oauth_error")
            if error:
                session.pop("pending_oauth", None)
                return redirect(f"/connect/failed?reason={error}")

            return redirect(f"/connect/connecting?state={pending['state']}")
        ```
      </Tab>
    </Tabs>

    <Callout type="info">
      **You don't need your own CSRF token on the OAuth flow.** Layers' `state` token is opaque and validated server-side against the API-key scope at every callback. Adding a second CSRF layer is harmless but redundant.
    </Callout>
  </Step>

  <Step>
    ## Poll `oauth-status` until it flips to `completed` [#poll-oauth-status-until-it-flips-to-completed]

    <Endpoint method="GET" path="/v1/social/oauth-status/:state" scope="social:read" phase="1" />

    Same `state` token you stashed in step 2. Poll on a 5-second cadence until `status` flips. The state token lives for 10 minutes; if it expires before consent completes, the row terminates with `error.code = state_expired`.

    <Callout type="info">
      Prefer event-driven? Subscribe to the `social_account.connected` webhook (see [Webhooks → Event catalog](/docs/api/operational/webhooks#event-catalog)) and skip the poll entirely. The webhook payload carries the same `socialAccountId`, `platform`, and `connectedAt` fields. See **Webhooks instead of polling** below for the comparison.
    </Callout>

    <Tabs items="['TypeScript', 'Python']">
      <Tab value="TypeScript">
        ```ts title="poll-oauth-status.ts"
        const POLL_INTERVAL_MS = 5_000; // 5s floor: oauth-status is rate-limited and
                                        // poll-budget-tight; under 5s wastes budget.
        const MAX_POLL_MS = 10 * 60 * 1_000; // state row TTL.

        export async function pollOAuthStatus(state: string): Promise<string> {
          const deadline = Date.now() + MAX_POLL_MS;
          // Only used when we hit 429. Normal pending polls keep the fixed cadence.
          let backoffMs = POLL_INTERVAL_MS;

          while (Date.now() < deadline) {
            const res = await fetch(
              `https://api.layers.com/v1/social/oauth-status/${state}`,
              { headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY!}` } },
            );

            if (res.status === 429) {
              const retryAfter = Number(res.headers.get("Retry-After") ?? "5") * 1_000;
              await new Promise(r => setTimeout(r, Math.max(retryAfter, backoffMs)));
              backoffMs = Math.min(backoffMs * 2, 30_000);
              continue;
            }

            // Successful response — reset the 429 backoff so we don't carry it
            // forward into the next pending poll.
            backoffMs = POLL_INTERVAL_MS;

            const body = await res.json();
            if (body.status === "completed") return body.socialAccountId as string;
            if (body.status === "failed") {
              throw new Error(`oauth failed: ${body.error?.code} — ${body.error?.message}`);
            }

            await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
          }

          throw new Error("oauth state expired before user completed consent");
        }
        ```
      </Tab>

      <Tab value="Python">
        ```python title="poll_oauth_status.py"
        import os
        import time
        import requests

        POLL_INTERVAL_S = 5  # 5s floor.
        MAX_POLL_S = 10 * 60  # state row TTL.

        def poll_oauth_status(state: str) -> str:
            deadline = time.monotonic() + MAX_POLL_S
            # Only used when we hit 429. Normal pending polls keep the fixed cadence.
            backoff_s = POLL_INTERVAL_S

            while time.monotonic() < deadline:
                r = requests.get(
                    f"https://api.layers.com/v1/social/oauth-status/{state}",
                    headers={"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}"},
                    timeout=10,
                )
                if r.status_code == 429:
                    retry_after = int(r.headers.get("Retry-After", "5"))
                    time.sleep(max(retry_after, backoff_s))
                    backoff_s = min(backoff_s * 2, 30)
                    continue

                # Successful response — reset the 429 backoff so we don't carry it
                # forward into the next pending poll.
                backoff_s = POLL_INTERVAL_S

                body = r.json()
                if body["status"] == "completed":
                    return body["socialAccountId"]
                if body["status"] == "failed":
                    err = body.get("error", {})
                    raise RuntimeError(f"oauth failed: {err.get('code')} — {err.get('message')}")

                time.sleep(POLL_INTERVAL_S)

            raise RuntimeError("oauth state expired before user completed consent")
        ```
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## Fetch the full account record [#fetch-the-full-account-record]

    <Endpoint method="GET" path="/v1/projects/:projectId/social-accounts" scope="social:read" phase="1" />

    `oauth-status` returns the minimum you need to start scheduling: `socialAccountId`, `platform`, `handle`, `connectedAt`. For the full record (`avatarUrl`, `tokenExpiresAt`, `status`), call the list endpoint:

    ```ts
    const res = await fetch(
      `https://api.layers.com/v1/projects/${projectId}/social-accounts?platform=tiktok`,
      { headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY!}` } },
    );
    const { items } = await res.json();
    const acmeAccount = items.find((a) => a.socialAccountId === socialAccountId);
    ```

    <Response status="200">
      ```json
      {
        "items": [
          {
            "socialAccountId": "sa_9c1e42a0-b7f3-4e5d-a2c1-8b4f5e6c7d8e",
            "platform": "tiktok",
            "handle": "acmecoffee",
            "avatarUrl": "https://media.layers.com/tiktok/acmecoffee/avatar.jpg",
            "status": "connected",
            "leased": false,
            "connectedAt": "2026-04-18T19:06:42Z",
            "tokenExpiresAt": "2026-05-18T19:06:42Z"
          }
        ],
        "nextCursor": null
      }
      ```
    </Response>
  </Step>

  <Step>
    ## Show the connection in your UI [#show-the-connection-in-your-ui]

    Persist the `socialAccountId` against the customer in your own DB, then render their connected account:

    ```tsx title="ConnectedAccount.tsx"
    // Shape returned by GET /v1/projects/:projectId/social-accounts.items[].
    // Define your own type from the response — Layers doesn't ship a partner SDK today.
    type SocialAccount = {
      socialAccountId: string;
      platform: "tiktok" | "instagram";
      handle: string;
      avatarUrl: string | null;
      status: "connected" | "reauth_required" | "disconnected";
      leased: boolean;
      connectedAt: string;
      tokenExpiresAt: string | null;
    };

    export function ConnectedAccount({ account }: { account: SocialAccount }) {
      return (
        <div className="flex items-center gap-3">
          <img src={account.avatarUrl ?? ""} alt="" className="h-10 w-10 rounded-full" />
          <div>
            <div className="font-medium">{account.handle}</div>
            <div className="text-sm text-muted-foreground">
              TikTok · Connected {new Date(account.connectedAt).toLocaleDateString()}
            </div>
          </div>
          <RevokeButton socialAccountId={account.socialAccountId} />
        </div>
      );
    }
    ```

    From here, the `socialAccountId` is a valid target for [`POST /v1/content/:containerId/publish`](/docs/api/reference/publishing/publish-content) (or [`/schedule`](/docs/api/reference/publishing/schedule-content) for a future time) — that's how content makes it onto the platform.
  </Step>
</Steps>

## Webhooks instead of polling [#webhooks-instead-of-polling]

If you've already set up [webhook endpoints](/docs/api/operational/webhooks), subscribe to `social_account.connected` and drop the poll loop entirely. The webhook fires the moment the social account row is persisted on Layers' side — same point in time as `oauth-status` flipping to `completed`.

```http
POST https://your-handler.example.com/layers-webhook HTTP/1.1
Content-Type: application/json
X-Layers-Event-Type: social_account.connected
X-Layers-Signature: t=...,v1=...

{
  "id": "evt_01KPM7QZEC6NJF4XJTCZRR6S3N",
  "type": "social_account.connected",
  "apiVersion": "v1",
  "createdAt": "2026-04-18T19:06:42Z",
  "data": {
    "socialAccountId": "sa_9c1e42a0-b7f3-4e5d-a2c1-8b4f5e6c7d8e",
    "platform": "tiktok",
    "displayName": "Acme Coffee",
    "connectedAt": "2026-04-18T19:06:42Z"
  }
}
```

The two paths aren't exclusive — most partners run both during the cutover window. See [Webhooks → Migrating from polling](/docs/api/operational/webhooks#migrating-from-polling).

|                     | Polling                                  | Webhooks                                      |
| ------------------- | ---------------------------------------- | --------------------------------------------- |
| **Latency**         | up to 5s (your poll cadence)             | sub-second                                    |
| **API budget**      | Counts against `social:read` poll budget | Free (delivery side, not API budget)          |
| **Setup cost**      | Zero — just call the endpoint            | Endpoint + signature verification + dedupe    |
| **Right call when** | First integration, low connect volume    | Production scale, sub-second feedback matters |

## Handling reauth [#handling-reauth]

Tokens expire (TikTok rotates roughly monthly, Instagram every 60 days). Platforms also invalidate tokens when the end-user changes their password or revokes the app. Either way the account's `status` flips to `reauth_required`, and any scheduled posts targeting it start failing with `CREDENTIAL_INVALID`.

Detect it on a list call and mint a reauth URL:

```ts
const { items } = await fetch(
  `https://api.layers.com/v1/projects/${projectId}/social-accounts`,
  { headers: { Authorization: `Bearer ${LAYERS_API_KEY}` } },
).then(r => r.json());

const needsReauth = items.filter(a => a.status === "reauth_required");

for (const account of needsReauth) {
  const res = await fetch(
    `https://api.layers.com/v1/projects/${projectId}/social/reauth-url`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${LAYERS_API_KEY}`,
        "Idempotency-Key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        socialAccountId: account.socialAccountId,
        returnUrl: `${process.env.APP_BASE_URL}/connect/${account.platform}/return`,
      }),
    },
  ).then(r => r.json());

  // Email the user a link to res.authorizeUrl, or surface a "Reconnect" banner
  // in your UI. The flow from here is identical to the initial connect —
  // platform consent, /oauth/return handler, poll oauth-status. On success the
  // tokens bind back to the SAME socialAccountId (handle and scheduled posts
  // are preserved).
}
```

<Callout type="info">
  **`social_account.needs_reauth` isn't emitting yet.** The event is registered in the [webhook catalog](/docs/api/operational/webhooks#event-catalog) — you can subscribe today and the delivery will start landing once the emit hook ships — but right now polling `social-accounts?status=reauth_required` is the only signal. Plan around polling for the time being.
</Callout>

## Revoking an account [#revoking-an-account]

When the end-customer wants to disconnect — or you're offboarding them — call `DELETE /v1/social-accounts/:socialAccountId`. Layers drops the token, marks the account `disconnected`, and cancels every queued scheduled post in a single transaction.

```ts
const res = await fetch(
  `https://api.layers.com/v1/social-accounts/${socialAccountId}`,
  {
    method: "DELETE",
    headers: { Authorization: `Bearer ${LAYERS_API_KEY}` },
  },
);
const { canceledScheduledPosts } = await res.json();
// Surface this count to the user — "We canceled 4 queued posts on this account."
```

Already-published posts stay on the platform. Layers doesn't delete upstream content.

## Testing locally [#testing-locally]

You don't need to deploy to test the flow end-to-end — you just need to be reachable from the platform's redirect.

* **Allowlist `localhost`.** `http://localhost` (with any port) is the one exception to the otherwise `https://`-only allowlist rule. Ask your account manager to add `localhost` to your API key's allowlist for local dev. You can keep your production hosts (`app.your-product.com`) on the same key.
* **The platform redirects to Layers, not your localhost.** TikTok and Instagram won't redirect to `http://localhost` — they require an `https://` redirect URI registered in the platform's app dashboard. Layers' callback server sits between the platform and your `returnUrl`: the platform redirects to `https://api.layers.com/api/partner/v1/social/oauth-callback/<platform>` first, Layers finishes the handshake, then 302s the end-customer to your local `returnUrl`. You don't need to register `localhost` with TikTok or Instagram.
* **Sandbox mode.** For automated tests and CI, use a sandbox API key — it short-circuits the platform handshake entirely. See [Sandbox](/docs/api/concepts/sandbox) for the test-mode pattern.

## What can go wrong [#what-can-go-wrong]

| Symptom                                        | Likely cause                                                                                                                                      | Fix                                                                                |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `403 RETURN_URL_NOT_ALLOWED` on `oauth-url`    | Host not on the key's allowlist (response carries `details.host`).                                                                                | Email your account manager with the host.                                          |
| User clicks **Cancel** on TikTok               | Expected. Layers redirects with `?layers_oauth_error=platform_denied`.                                                                            | Show a "Try again" button.                                                         |
| User returns to your `returnUrl` after >10 min | State token expired. `oauth-status` returns `failed` with `error.code = state_expired`, or returnUrl carries `?layers_oauth_error=state_expired`. | Mint a fresh `oauth-url`.                                                          |
| Poll loop hits `429`                           | You're polling faster than 5s, or other partner traffic is consuming budget.                                                                      | Honor `Retry-After`. Exponential backoff on 429 (the sample code above does this). |
| `oauth-status` returns 404                     | State token unknown — either expired, never minted, or minted under a different API key. State rows are scoped by API key.                        | Confirm you're using the same key that minted the URL.                             |

## What's next [#whats-next]

<Cards>
  <Card title="Social accounts" href="/docs/api/concepts/social-accounts" description="Connected vs leased accounts, status lifecycle, what fires after a successful connect." />

  <Card title="oauth-url reference" href="/docs/api/reference/social-accounts/oauth-url" description="Full request/response, scopes catalog, state token handling guidance." />

  <Card title="Webhooks" href="/docs/api/operational/webhooks" description="Drop the poll loop — subscribe to social_account.connected." />

  <Card title="Publish content" href="/docs/api/reference/publishing/publish-content" description="Publish a post against your new socialAccountId — now or scheduled." />
</Cards>
