# GET /v1/social/oauth-status/:state (/docs/api/reference/social-accounts/oauth-status)



<Endpoint method="GET" path="/v1/social/oauth-status/:state" auth="Bearer" scope="social:read" phase="1" />

Poll this endpoint with the `state` you got back from [`POST /v1/projects/:id/social/oauth-url`](/docs/api/reference/social-accounts/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.

<Callout type="info">
  **Polling vs webhooks.** Polling is the simplest path and what most first integrations use. If you've already set up [webhook endpoints](/docs/api/operational/webhooks), 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](/docs/api/operational/webhooks#migrating-from-polling).
</Callout>

<Parameters
  title="Path"
  rows="[
  { name: 'state', type: 'string', required: true, description: 'Opaque state token from oauth-url. Case-sensitive.' },
]"
/>

## Example request [#example-request]

<Tabs items="['curl', 'TypeScript', 'Python']">
  <Tab value="curl">
    ```bash
    curl "https://api.layers.com/v1/social/oauth-status/st_01HXZ8K2M4P5QRS6TUV7WXYZ9A" \
      -H "Authorization: Bearer lp_..."
    ```
  </Tab>

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

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

## Response [#response]

<Response status="200" description="Pending - keep polling">
  ```json
  {
    "state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
    "status": "pending",
    "expiresAt": "2026-04-18T19:12:11Z"
  }
  ```
</Response>

<Response status="200" description="Completed - account connected">
  ```json
  {
    "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](/docs/api/concepts/social-accounts#what-happens-after-a-successful-connect) for the full chain.

  <Callout type="info">
    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`](/docs/api/reference/social-accounts/list-social-accounts) once you see `completed`. The list endpoint is the source of truth for everything beyond the just-connected handle.
  </Callout>
</Response>

<Response status="200" description="Failed - user denied or platform rejected">
  ```json
  {
    "state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
    "status": "failed",
    "error": {
      "code": "platform_denied",
      "message": "The user did not grant consent on the platform."
    }
  }
  ```
</Response>

## Failure error codes [#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.

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

## Errors [#errors]

See [Errors](/docs/api/operational/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).

| Status | Code              | When                                                                                                                                                                                                              |
| ------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `VALIDATION`      | `:state` path parameter missing or empty.                                                                                                                                                                         |
| 401    | `UNAUTHENTICATED` | Missing or invalid `Authorization` header.                                                                                                                                                                        |
| 403    | `FORBIDDEN_SCOPE` | Key lacks `social:read`.                                                                                                                                                                                          |
| 404    | `NOT_FOUND`       | `state` 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. |
| 429    | `RATE_LIMITED`    | Polling budget exhausted. Back off with the `Retry-After` header — do not poll faster than \~once every 5s during the consent window.                                                                             |
| 500    | `INTERNAL`        | Server-side failure reading the state row. `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`.                                                                                       |

## See also [#see-also]

* [`POST /v1/projects/:id/social/oauth-url`](/docs/api/reference/social-accounts/oauth-url) - create the URL
* [`GET /v1/projects/:id/social-accounts`](/docs/api/reference/social-accounts/list-social-accounts) - enumerate connected accounts
