Layers

GET /v1/social/oauth-status/:state

Poll the state of a pending OAuth handshake by state token.

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

Poll this endpoint with the state you got back from POST /v1/projects/:id/social/oauth-url to learn whether the end-customer completed consent and, if so, which socialAccountId was created.

The endpoint is not project-scoped - the state token uniquely identifies the attempt, and Layers scopes results by the API key that created it (a state created under key A is invisible to key B).

Poll until the OAuth attempt completes or expires. After expiry, create a new URL.

Polling vs webhooks. Polling is the simplest path and what most first integrations use. If you've already set up webhook endpoints, subscribe to social_account.connected instead — it fires the moment the social account row is persisted (same moment polling would flip to completed) and carries socialAccountId, platform, displayName, connectedAt. The two paths aren't exclusive; many partners poll during integration and migrate to webhooks for production. See Webhooks → Migrating from polling.

Path
  • state
    stringrequired
    Opaque state token from oauth-url. Case-sensitive.

Example request

curl "https://api.layers.com/v1/social/oauth-status/st_01HXZ8K2M4P5QRS6TUV7WXYZ9A" \
  -H "Authorization: Bearer lp_..."
const POLL_INTERVAL_MS = 5_000;
const MAX_POLL_MS = 10 * 60 * 1_000; // state row TTL.

async function waitForOAuth(state: string) {
  const deadline = Date.now() + MAX_POLL_MS;
  let backoffMs = POLL_INTERVAL_MS;

  while (Date.now() < deadline) {
    let status;
    try {
      status = await layers.social.getOAuthStatus({ state });
    } catch (err: any) {
      // 429 — honor Retry-After, then exponential backoff on subsequent 429s.
      if (err?.status === 429) {
        const retryAfterMs = Number(err.headers?.["retry-after"] ?? "5") * 1_000;
        await new Promise(r => setTimeout(r, Math.max(retryAfterMs, backoffMs)));
        backoffMs = Math.min(backoffMs * 2, 30_000);
        continue;
      }
      throw err;
    }

    if (status.status === "completed") return status.socialAccountId;
    if (status.status === "failed") {
      // status.error.code is one of the snake_case codes below (e.g.
      // "platform_denied", "exchange_failed"). Map to your own copy.
      throw new Error(status.error?.message ?? "OAuth failed");
    }

    // 5s floor — oauth-status is rate-limited and poll-budget-tight; under 5s
    // wastes budget without changing user-visible latency.
    await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
  }

  throw new Error("oauth state expired before user completed consent");
}
import time

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

def wait_for_oauth(state: str) -> str:
    deadline = time.monotonic() + MAX_POLL_S
    backoff_s = POLL_INTERVAL_S

    while time.monotonic() < deadline:
        try:
            status = layers.social.get_oauth_status(state=state)
        except RateLimitedError as e:  # SDK-shaped 429
            retry_after = int(e.headers.get("Retry-After", "5"))
            time.sleep(max(retry_after, backoff_s))
            backoff_s = min(backoff_s * 2, 30)
            continue

        if status["status"] == "completed":
            return status["socialAccountId"]
        if status["status"] == "failed":
            err = status.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")

Response

200Pending - keep polling
{
  "state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
  "status": "pending",
  "expiresAt": "2026-04-18T19:12:11Z"
}
200Completed - account connected
{
  "state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
  "status": "completed",
  "socialAccountId": "sa_9c1e42a0-b7f3-4e5d-a2c1-8b4f5e6c7d8e",
  "platform": "tiktok",
  "handle": "acmecoffee",
  "connectedAt": "2026-04-18T19:06:42Z"
}

A completed status means the social account is persisted and the post-connect side-effect chain has been queued: profile-picture rehost to Layers CDN, per-account distribution layer fan-out (Account Health Monitor, Social Distribution, Social Engagement, Account Warmup), Social Content layer ensure, and onboarding-task event emit. See Social accounts → After a successful connect for the full chain.

The completed payload only carries the fields you need to start scheduling — socialAccountId, platform, handle, connectedAt. For the full account record (avatarUrl, status, tokenExpiresAt, leased), call GET /v1/projects/:projectId/social-accounts once you see completed. The list endpoint is the source of truth for everything beyond the just-connected handle.

200Failed - user denied or platform rejected
{
  "state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
  "status": "failed",
  "error": {
    "code": "platform_denied",
    "message": "The user did not grant consent on the platform."
  }
}

Failure error codes

error.code on a failed status is always one of the snake_case constants below. The set is a stable public contract — codes will only ever be appended, never renamed or removed.

CodeWhen
state_expiredThe state row's expires_at (10 min from creation) has passed before the user completed consent. Create a new authorize URL.
platform_deniedThe end-customer cancelled consent on the platform's screen, or the platform returned error=access_denied.
platform_mismatchThe platform in the callback path didn't match the platform stored on the state row. Should not happen with normal use.
missing_codeThe platform redirected without a ?code parameter. Usually a transient platform-side bug; create a fresh URL and retry.
exchange_failedToken exchange against the platform's token endpoint failed (4xx/5xx). Inspect Layers' incident channel for upstream outages.
persistence_errorLayers couldn't write the resulting social_accounts row. Always a Layers-side bug — file a support ticket with the state.

Errors

See Errors for the canonical envelope. These are transport-level errors on the poll call itself — a terminal-failed OAuth attempt returns 200 with status: "failed" (see the failure-codes table above).

StatusCodeWhen
400VALIDATION:state path parameter missing or empty.
401UNAUTHENTICATEDMissing or invalid Authorization header.
403FORBIDDEN_SCOPEKey lacks social:read.
404NOT_FOUNDstate unknown, or created under a different API key. State rows are scoped to the key that minted them — a state created with key A is invisible to key B even when both keys live under the same organization.
429RATE_LIMITEDPolling budget exhausted. Back off with the Retry-After header — do not poll faster than ~once every 5s during the consent window.
500INTERNALServer-side failure reading the state row. 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.

See also

On this page