Layers

Connect your first social account

A 15-minute walkthrough — from "Connect TikTok" button to a live socialAccountId you can schedule against. The full server glue, not just the curl calls.

View as Markdown

What you'll 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

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 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. The projectId is what scopes the OAuth flow.

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.

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.

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.

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>
  );
}

Mint the authorize URL on your server

POST/v1/projects/:projectId/social/oauth-url
Phase 1stableidempotent
Auth
Bearer
Scope
social:write

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.

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);
}
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);
});
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"])
200
{
  "authorizeUrl": "https://www.tiktok.com/v2/auth/authorize?client_key=...&state=st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
  "state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
  "expiresAt": "2026-04-18T19:12:11Z"
}

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.

Implement the /oauth/return 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):

layers_oauth_errorUser-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.)
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}`);
}
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}`);
});
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']}")

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.

Poll oauth-status until it flips to completed

GET/v1/social/oauth-status/:state
Phase 1stable
Auth
Bearer
Scope
social:read

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.

Prefer event-driven? Subscribe to the social_account.connected webhook (see 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.

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");
}
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")

Fetch the full account record

GET/v1/projects/:projectId/social-accounts
Phase 1stable
Auth
Bearer
Scope
social:read

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:

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);
200
{
  "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
}

Show the connection in your UI

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

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 (or /schedule for a future time) — that's how content makes it onto the platform.

Webhooks instead of polling

If you've already set up webhook endpoints, 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.

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.

PollingWebhooks
Latencyup to 5s (your poll cadence)sub-second
API budgetCounts against social:read poll budgetFree (delivery side, not API budget)
Setup costZero — just call the endpointEndpoint + signature verification + dedupe
Right call whenFirst integration, low connect volumeProduction scale, sub-second feedback matters

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:

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).
}

social_account.needs_reauth isn't emitting yet. The event is registered in the webhook 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.

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.

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

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 for the test-mode pattern.

What can go wrong

SymptomLikely causeFix
403 RETURN_URL_NOT_ALLOWED on oauth-urlHost not on the key's allowlist (response carries details.host).Email your account manager with the host.
User clicks Cancel on TikTokExpected. Layers redirects with ?layers_oauth_error=platform_denied.Show a "Try again" button.
User returns to your returnUrl after >10 minState 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 429You'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 404State 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

On this page