GET /v1/social/oauth-status/:state
Poll the state of a pending OAuth handshake by state token.
/v1/social/oauth-status/:state- 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.
statestringrequiredOpaque 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
{
"state": "st_01HXZ8K2M4P5QRS6TUV7WXYZ9A",
"status": "pending",
"expiresAt": "2026-04-18T19:12:11Z"
}{
"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.
{
"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.
| 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
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).
| 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 partner API has been disabled. details.scope is key, organization, or global. |
See also
POST /v1/projects/:id/social/oauth-url- create the URLGET /v1/projects/:id/social-accounts- enumerate connected accounts