# POST /v1/projects/:projectId/social/oauth-url (/docs/api/reference/social-accounts/oauth-url)



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

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.

<Callout type="info">
  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`](/docs/api/reference/social-accounts/oauth-status) 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.
</Callout>

<Parameters
  title="Path"
  rows="[
  { name: 'projectId', type: 'string', required: true, description: 'Project that will own the resulting social account.' },
]"
/>

<Parameters
  title="Body"
  rows="[
  { name: 'platform', type: 'string', required: true, enum: ['tiktok', 'instagram'], description: 'Target platform.' },
  { name: 'returnUrl', type: 'string (URL)', required: true, description: '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 &#x22;Setting up your returnUrl allowlist&#x22; section below.' },
  { name: 'scopes', type: 'string[]', description: 'Platform scopes to request. Defaults to the full set Layers needs for publishing and metrics — see &#x22;Default scopes&#x22; 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`.' },
  { name: 'usageNote', type: 'string', description: '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.

<Callout type="info">
  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.
</Callout>

## Setting up your returnUrl allowlist [#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`](/docs/api/reference/organizations/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.

<Callout type="warn">
  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.
</Callout>

## Default scopes [#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) [#tiktok-defaults-10-scopes]

| Scope                 | Grants                                                                                                                                 |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `user.info.basic`     | The 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.username`  | The user's `@`-less username (e.g. `acmecoffee`). What Layers returns as `handle`.                                                     |
| `user.info.profile`   | Display name + profile picture URL. Layers rehosts the avatar to its own CDN at connect time.                                          |
| `user.info.stats`     | Follower count, following count, video count, total likes received. Used to populate the Account Health Monitor surface.               |
| `video.list`          | Read the user's TikTok video library. Used to sync posts and historical performance.                                                   |
| `video.insights`      | Per-video analytics (views, watch time, completion rate, demographic breakdowns). Powers the metrics endpoints.                        |
| `video.upload`        | Upload a video file to the user's account via the direct-post flow. Used by `publish` mode.                                            |
| `video.publish`       | Submit an uploaded video for posting. Pairs with `video.upload`.                                                                       |
| `comment.list`        | Read comments on the user's posts. Powers the engagement features.                                                                     |
| `comment.list.manage` | Hide, unhide, and delete comments. Used by auto-pilot engagement moderation.                                                           |

Reference: [TikTok OAuth scopes catalog](https://developers.tiktok.com/doc/tiktok-api-v2-scopes) (Layers uses the TikTok for Business OAuth flow at the `business-api.tiktok.com` token endpoint).

### Instagram defaults (4 scopes) [#instagram-defaults-4-scopes]

| Scope                                | Grants                                                                                                                          |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `instagram_business_basic`           | The IG Business account's `id`, `username`, `account_type`, and profile picture URL. Required for every IG Business Login flow. |
| `instagram_business_content_publish` | Publish images, videos, reels, and carousels to the connected account. Required for direct publishing.                          |
| `instagram_business_manage_insights` | Read post-level and account-level performance metrics (reach, impressions, engagement).                                         |
| `instagram_business_manage_comments` | Read, hide, and reply to comments on the account's posts. Powers engagement features.                                           |

Reference: [Instagram Login permissions](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login) (Layers uses the Instagram Business Login flow at `api.instagram.com/oauth/access_token` — NOT Facebook Login).

<Callout type="info">
  **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.
</Callout>

### Overriding the defaults [#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 [#example-request]

<Tabs items="['curl', 'TypeScript', 'Python']">
  <Tab value="curl">
    ```bash
    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"
      }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    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);
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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
    ```
  </Tab>
</Tabs>

## Response [#response]

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

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`](/docs/api/reference/social-accounts/oauth-status) to learn the resulting `socialAccountId`.

<Callout type="warn">
  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`](/docs/api/reference/social-accounts/oauth-status) — 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`](/docs/api/reference/social-accounts/list-social-accounts) afterwards — the `oauth-status` response only carries the minimal fields you need to start scheduling.
</Callout>

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 [#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`](/docs/api/reference/social-accounts/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):

| Code                | When                                                                                                                                                                                                                      | What to do                                                                                                                                                   |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `state_expired`     | The 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_denied`   | The 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_code`      | The 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_failed`   | Layers 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_error` | Token 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_mismatch` | The 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_terminal`    | You 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`](/docs/api/reference/social-accounts/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-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 [#errors]

See [Errors](/docs/api/operational/errors) for the canonical envelope and full code catalog. Endpoint-specific notes:

| Status | Code                     | When                                                                                                                                                                                                                                                            |
| ------ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `VALIDATION`             | Request body could not be parsed as JSON. Schema-level validation failures (unknown `platform`, malformed `returnUrl`, unknown fields under `.strict()`) return `422` instead — see below.                                                                      |
| 401    | `UNAUTHENTICATED`        | Missing or invalid `Authorization` header.                                                                                                                                                                                                                      |
| 403    | `FORBIDDEN_SCOPE`        | Key lacks `social:write`. `details.requiredScope` names the scope.                                                                                                                                                                                              |
| 403    | `RETURN_URL_NOT_ALLOWED` | `returnUrl` 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.                                                                                              |
| 404    | `NOT_FOUND`              | Project not in your organization. Returned (not `403`) deliberately so the API doesn't leak cross-org existence.                                                                                                                                                |
| 422    | `VALIDATION`             | Body 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. |
| 429    | `RATE_LIMITED`           | Per-key write budget exhausted for endpoint class `oauth`. Honor `Retry-After` and the `X-RateLimit-*` headers.                                                                                                                                                 |
| 500    | `INTERNAL`               | Server-side failure — typically a vault write for the PKCE verifier or a `partner_oauth_states` insert. `requestId` correlates against Layers logs. Retry with backoff.                                                                                         |
| 503    | `KILL_SWITCH`            | Your key, your org, or the global API integration has been disabled. `details.scope` is `key`, `organization`, or `global`. Only `global` is auto-recoverable.                                                                                                  |

## See also [#see-also]

* [`GET /v1/social/oauth-status/:state`](/docs/api/reference/social-accounts/oauth-status) - poll completion (not project-scoped)
* [`GET /v1/projects/:projectId/social-accounts`](/docs/api/reference/social-accounts/list-social-accounts) - list connected accounts
* [`POST /v1/projects/:projectId/social/reauth-url`](/docs/api/reference/social-accounts/reauth-url) - refresh an existing connection
* [Social accounts](/docs/api/concepts/social-accounts) - how Layers stores and refreshes tokens
