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.
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:
- A live API key with
social:writeandsocial: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. - Your return URL host on the key's allowlist. Layers checks the
returnUrlyou 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,localhostfor local dev). Updates take effect within minutes; you don't need to re-mint the key. - A project in your org to attach the account to. Either create one in the dashboard or via
POST /v1/projects. TheprojectIdis 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)
- Your server calls Layers to mint a Layers-hosted authorize URL with an opaque
statetoken. - Your UI sends the end-customer to that URL (top-level redirect — it can't be framed).
- They consent on
tiktok.com/instagram.com. Both the success path and the failure path redirect them back to areturnUrlyou supplied — Layers never re-hosts the consent UI. - Your
returnUrlhandler either sees no extra query params (success) or?layers_oauth_error=<code>(failure). - Your server polls Layers' status endpoint with the same
statetoken until it flips tocompleted, at which point you get the newsocialAccountIdback.
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.
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
/v1/projects/:projectId/social/oauth-url- 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.
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);
}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);
});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"]){
"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_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.) |
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}`);
}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_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
/v1/social/oauth-status/:state- 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.
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");
}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
/v1/projects/:projectId/social-accounts- 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);{
"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:
// 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.
| 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
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 otherwisehttps://-only allowlist rule. Ask your account manager to addlocalhostto 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 anhttps://redirect URI registered in the platform's app dashboard. Layers' callback server sits between the platform and yourreturnUrl: the platform redirects tohttps://api.layers.com/api/partner/v1/social/oauth-callback/<platform>first, Layers finishes the handshake, then 302s the end-customer to your localreturnUrl. You don't need to registerlocalhostwith 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
| 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
Social accounts
Connected vs leased accounts, status lifecycle, what fires after a successful connect.
oauth-url reference
Full request/response, scopes catalog, state token handling guidance.
Webhooks
Drop the poll loop — subscribe to social_account.connected.
Publish content
Publish a post against your new socialAccountId — now or scheduled.
Manage customers with sub-organizations
Give every customer an isolated child org — create one, work inside it with your parent key, suspend it as a kill switch, and offboard it in a single call.
Upload finished content
Bring your own finished posts. Two transports, byte-for-byte publishing, advisory platform fit, and a quota that always names its limits.