Layers

POST /v1/projects/:projectId/social/oauth-url

Create a platform OAuth URL so an end-customer can connect their TikTok or Instagram account. You control the returnUrl.

View as Markdown
POST/v1/projects/:projectId/social/oauth-url
Phase 1stable
Auth
Bearer
Scope
social:write

Create an OAuth authorization URL for TikTok or Instagram. The end-customer opens it in a top-level browser tab, consents on the platform's domain, and is redirected back to a returnUrl you control - not to Layers.

You pass returnUrl per call. It must match an allowlist on your API key; anything else is rejected with RETURN_URL_NOT_ALLOWED. Layers never re-hosts the consent page, and the URL cannot be framed (TikTok and Instagram block their domains from iframes by ToS). Your UI opens it in a new tab or redirects the user to it directly.

The authorize URL lives on tiktok.com or instagram.com. It will not render inside an iframe. Open it in a top-level tab, then poll GET /v1/social/oauth-status/:state until it flips to completed. The status endpoint is not project-scoped - the state token uniquely identifies the attempt and is scoped by API key.

Path
  • projectId
    stringrequired
    Project that will own the resulting social account.
Body
  • platform
    stringrequired
    Target platform.
    One of: tiktok, instagram
  • returnUrl
    string (URL)required
    Where Layers redirects the end-customer after consent. The host (case-insensitive) must exactly match a domain on your API key's allowlist; mismatches return `403 RETURN_URL_NOT_ALLOWED`. Allowlist updates are not self-service today — contact your Layers account manager to add a domain. See the "Setting up your returnUrl allowlist" section below.
  • scopes
    string[]optional
    Platform scopes to request. Defaults to the full set Layers needs for publishing and metrics — see "Default scopes" section below for the complete list per platform. Override only when you have a specific reason; the platform app review only approves redirect URIs against the registered scope set, and unapproved combinations are rejected with `Invalid redirect_uri`.
  • usageNote
    stringoptional
    Shown to the user on your side after the callback (if you render one). Opaque to Layers.

Request bodies are strict — sending an unknown field returns 422 VALIDATION with the offending key in details.issues[]. Don't rely on Layers silently dropping fields it doesn't recognize.

This endpoint does not honor the Idempotency-Key header. The response carries a 10-minute-lived state token; caching responses on a longer window than the state's lifetime would replay stale URLs that fail at consent time with state_expired. If you need to deduplicate retries client-side, key off your own end-user-action ID and short-circuit before calling Layers — don't rely on Idempotency-Key here.

Setting up your returnUrl allowlist

Every API key has an allowlist of domains that returnUrl is checked against at call time. The allowlist is stored on the key itself and lives independently of platform OAuth — it's a Layers-side guardrail, not a TikTok or Instagram setting.

How matching works:

  • Host comparison only — scheme, port, and path are ignored
  • Case-insensitive ("App.Example.com" matches "app.example.com")
  • Exact host match — no wildcards, no subdomain inheritance. If your UI lives at app.example.com and dashboard.example.com, both hosts need to be on the allowlist
  • localhost is permitted for local development (the only http:// exception — every other host must be https://)

How to get a domain added:

Allowlist updates are not self-service today. To add or remove a domain, contact your Layers account manager with:

  • The API key name or ID (visible on GET /v1/whoami as apiKeyId)
  • The domain(s) you want allowed (e.g., app.example.com)
  • Your environment context (e.g., "staging" vs "production")

Updates typically take effect within a few minutes of being saved. You don't need to re-mint the API key — the existing key picks up the new allowlist automatically.

Don't try to work around an unallowed returnUrl by routing through a proxy or open redirector — that defeats the security property the allowlist is enforcing (preventing a stolen API key from pivoting OAuth handshakes to an attacker-controlled domain). The right path is always to get the domain explicitly added.

Default scopes

If you don't pass scopes in the body, Layers requests the platform-specific default set below. These are the scopes the Layers app has approved with each platform during app review, and they cover everything needed for publishing, metrics, and comment moderation through the Layers API.

TikTok defaults (10 scopes)

ScopeGrants
user.info.basicThe user's TikTok open_id and union_id. Required by every TikTok OAuth flow — without this scope the platform won't issue a token.
user.info.usernameThe user's @-less username (e.g. acmecoffee). What Layers returns as handle.
user.info.profileDisplay name + profile picture URL. Layers rehosts the avatar to its own CDN at connect time.
user.info.statsFollower count, following count, video count, total likes received. Used to populate the Account Health Monitor surface.
video.listRead the user's TikTok video library. Used to sync posts and historical performance.
video.insightsPer-video analytics (views, watch time, completion rate, demographic breakdowns). Powers the metrics endpoints.
video.uploadUpload a video file to the user's account via the direct-post flow. Used by publish mode.
video.publishSubmit an uploaded video for posting. Pairs with video.upload.
comment.listRead comments on the user's posts. Powers the engagement features.
comment.list.manageHide, unhide, and delete comments. Used by auto-pilot engagement moderation.

Reference: TikTok OAuth scopes catalog (Layers uses the TikTok for Business OAuth flow at the business-api.tiktok.com token endpoint).

Instagram defaults (4 scopes)

ScopeGrants
instagram_business_basicThe IG Business account's id, username, account_type, and profile picture URL. Required for every IG Business Login flow.
instagram_business_content_publishPublish images, videos, reels, and carousels to the connected account. Required for direct publishing.
instagram_business_manage_insightsRead post-level and account-level performance metrics (reach, impressions, engagement).
instagram_business_manage_commentsRead, hide, and reply to comments on the account's posts. Powers engagement features.

Reference: Instagram Login permissions (Layers uses the Instagram Business Login flow at api.instagram.com/oauth/access_token — NOT Facebook Login).

Account type requirement for Instagram: the user's Instagram account must be a Business or Creator account (not Personal). Personal accounts can authorize via instagram_business_* scopes, but post-publishing calls will fail with platform-side permission errors at scheduling time. Direct end-users to switch under IG → Settings → Account type → Switch to Creator (or Business) before consenting.

Overriding the defaults

You can pass scopes in the request body to override the default set — but with two caveats:

  1. Subset only. The platform's app config maps each redirect URI to an approved scope set. Requesting scopes outside what's approved (e.g. adding TikTok's biz.spark.auth or IG's deprecated instagram_basic) will return Invalid redirect_uri post-authentication, even though the URI is byte-correct in the registered list. If you have a real need for a non-default scope set, contact your Layers account manager before going live.

  2. Bounds. Max 32 scopes per call, each ≤ 64 characters. Schema enforced at request time; violations return 422 VALIDATION.

If you only need a narrower flow (e.g. metrics-only, no publishing), passing a smaller scope subset of the defaults is supported — the platform's approval allows any subset of the approved set, just not adding new scopes outside it.

Example request

curl https://api.layers.com/v1/projects/prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39/social/oauth-url \
  -H "Authorization: Bearer lp_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "tiktok",
    "returnUrl": "https://app.gicgrowth.com/connect/tiktok/return",
    "usageNote": "Connecting Acme Coffee"
  }'
const { authorizeUrl, state } = await layers.social.createOAuthUrl({
  projectId: "prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39",
  platform: "tiktok",
  returnUrl: "https://app.gicgrowth.com/connect/tiktok/return",
  usageNote: "Connecting Acme Coffee",
});

window.location.assign(authorizeUrl);
result = layers.social.create_oauth_url(
    project_id="prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39",
    platform="tiktok",
    return_url="https://app.gicgrowth.com/connect/tiktok/return",
    usage_note="Connecting Acme Coffee",
)
# send result["authorizeUrl"] to the end-customer's browser

Response

200OAuth URL created
{
  "authorizeUrl": "https://www.tiktok.com/v2/auth/authorize?client_key=...&state=st_01HXZ8...",
  "state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
  "expiresAt": "2026-04-18T19:12:11Z"
}

The returned state is a Layers-created opaque token - do not reuse or modify it. When the end-customer returns to your returnUrl, Layers appends ?layers_oauth_error=<code> only on failure (the success case carries no extra params - Layers has already persisted the social account). Pass the same state to GET /v1/social/oauth-status/:state to learn the resulting socialAccountId.

This response intentionally does not include account details. At this point the end-customer has not yet granted consent on the platform, so no social_accounts row exists. Use the returned state to call GET /v1/social/oauth-status/:state — once that flips to completed you get back the socialAccountId, platform, handle, and connectedAt.

For the full account record (avatarUrl, status, tokenExpiresAt, leased), call GET /v1/projects/:projectId/social-accounts afterwards — the oauth-status response only carries the minimal fields you need to start scheduling.

The authorize URL host depends on platform: TikTok → www.tiktok.com/v2/auth/authorize, Instagram → www.instagram.com/oauth/authorize (Instagram Login flow, not Facebook Login). Both reject framing in iframes — open in a top-level tab.

Authorize URLs and state tokens expire after 10 minutes. After expiry, create a new one — do not cache. On expiry the state moves to failed with error.code = state_expired.

What lands at your returnUrl

After consent (success or failure), Layers redirects the user's browser to the returnUrl you supplied. On success the URL is clean — no query params added — and your job is to call oauth-status with the state you've already stored to learn the resulting socialAccountId. On failure Layers appends ?layers_oauth_error=<code> so your handler can branch on it.

Codes that can appear in ?layers_oauth_error (snake_case, stable contract — additive only):

CodeWhenWhat to do
state_expiredThe user took more than 10 minutes between opening the authorize URL and finishing consent. The state row is now terminal-failed.Tell the user the link expired and mint a fresh authorize URL.
platform_deniedThe user clicked Cancel on TikTok's or Instagram's consent screen, or the platform returned error=access_denied for another reason (account suspended, scope rejected).Tell the user the connection was cancelled. Offer a "Try again" button that mints a fresh authorize URL.
missing_codeThe platform redirected back without the ?code query param. Usually a transient platform-side bug.Retry with a fresh authorize URL. If it persists, check the platform's status page.
exchange_failedLayers tried to exchange the platform's code for tokens and the platform's token endpoint returned 4xx or 5xx. Common causes: revoked app credentials on the platform side, platform outage, mismatched redirect_uri.Retry once with a fresh authorize URL. If it keeps failing, file a support ticket with the state — Layers logs the platform-side response code on our end.
persistence_errorToken exchange succeeded but Layers couldn't write the resulting social_accounts row. Always a Layers-side bug.File a support ticket with the state.
platform_mismatchThe platform in the callback path didn't match the platform stored on the state row. Should never happen with normal use — only fires if a state token was hand-edited or replayed against the wrong platform.Mint a fresh authorize URL.
state_terminalYou sent the user back through the same authorize URL after it had already failed once. The state row was already failed and Layers refused to re-process it.Don't replay terminal states. Mint a fresh authorize URL.

When the state row can't even be loaded (state token unknown, or the URL has no state param at all), Layers can't redirect — there's no recoverable returnUrl. The user sees a Layers-hosted "Invalid or expired state" page instead. Design your UI to handle the case where the user never comes back to your returnUrl at all.

The same six failure codes (every one above except state_terminal) also surface on oauth-status as error.code on a failed status — so a partner that prefers polling-only doesn't need to parse returnUrl query params. state_terminal is specific to returnUrl (it represents a replay, not a fresh failure).

State token handling

state is the handle you keep between minting the authorize URL and learning the resulting socialAccountId. A few things worth pinning down before you ship:

  • Store it server-side, not in a cookie. Keep it in your session store (Redis, your DB, signed JWTs you own) keyed by the user's session. The state itself is the lookup token Layers needs back — losing it means the partner can't poll oauth-status or recover the new socialAccountId. A cookie the user can read or clear is not safe primary storage.
  • You don't need your own CSRF token. Layers' state is opaque, scoped by API key, and validated server-side at every callback. Adding a second CSRF layer is harmless but redundant — Layers' state validation is the security property.
  • state is sensitive-ish. Treat it like a session ID: don't log it in plaintext where a customer-facing log search can reach, don't expose it cross-tenant. It isn't a credential (it can't sign requests on its own), but it does grant read access to one specific OAuth attempt's result. The expiresAt window (10 min) limits blast radius.
  • If your server restarts mid-flow, the handshake is lost. The state row Layers holds is still valid until expiry, but without your stored copy you can't poll oauth-status for the result. Persist state to durable storage before redirecting the user, not just in-process memory.
  • One state per attempt. Don't reuse state across retries — if the row is failed or completed, Layers refuses to re-process it (state_terminal). Mint a fresh URL for each retry.

Errors

See Errors for the canonical envelope and full code catalog. Endpoint-specific notes:

StatusCodeWhen
400VALIDATIONRequest body could not be parsed as JSON. Schema-level validation failures (unknown platform, malformed returnUrl, unknown fields under .strict()) return 422 instead — see below.
401UNAUTHENTICATEDMissing or invalid Authorization header.
403FORBIDDEN_SCOPEKey lacks social:write. details.requiredScope names the scope.
403RETURN_URL_NOT_ALLOWEDreturnUrl host is not on this key's allowlist. details.returnUrl and details.host echo what you sent. Contact your Layers account manager to add the domain.
404NOT_FOUNDProject not in your organization. Returned (not 403) deliberately so the API doesn't leak cross-org existence.
422VALIDATIONBody parsed but failed schema validation: unknown platform, returnUrl not an absolute URL, unknown field rejected by .strict(), scopes exceeds bounds (≤32 entries, each ≤64 chars), usageNote over 512 chars. details.issues[] lists each failure.
429RATE_LIMITEDPer-key write budget exhausted for endpoint class oauth. Honor Retry-After and the X-RateLimit-* headers.
500INTERNALServer-side failure — typically a vault write for the PKCE verifier or a partner_oauth_states insert. requestId correlates against Layers logs. Retry with backoff.
503KILL_SWITCHYour key, your org, or the global partner API has been disabled. details.scope is key, organization, or global. Only global is auto-recoverable.

See also

On this page