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



<Endpoint method="POST" path="/v1/projects/{projectId}/ads/ad-accounts/oauth-url" auth="Bearer" scope="ads:write" phase="1" />

Returns a short-lived OAuth URL your user opens to grant Layers access to their ad account. Layers' ad platform integrations are bring-your-own - this is the only path to connect an ad account today. The URL itself is single-use and scoped to one project, one platform, and one user session.

<Parameters
  title="Path"
  rows="[
  { name: 'projectId', type: 'string (UUID)', required: true, description: 'Project the new ad account will be attached to.' },
]"
/>

<Parameters
  title="Body"
  rows="[
  { name: 'platform', type: 'string', required: true, description: 'Which platform to connect.', enum: ['meta_ads', 'tiktok_ads', 'apple_ads'] },
  { name: 'returnUrl', type: 'string (URL)', required: true, description: 'Where to send the user after they complete (or reject) the OAuth flow. Must be HTTPS and match an allowed return URL on your API key. Same field name as `/social/oauth-url`.' },
  { name: 'redirectUrl', type: 'string (URL)', deprecated: true, description: 'Deprecated alias for `returnUrl`. Accepted for backwards compatibility for one release cycle and then removed. Send `returnUrl` going forward.' },
  { name: 'state', type: 'string', description: 'Opaque string echoed back in the redirect. Use it to round-trip your own request id.' },
]"
/>

## Example request [#example-request]

<Tabs items="['curl', 'TypeScript', 'Python']">
  <Tab value="curl">
    ```bash
    curl -X POST https://api.layers.com/v1/projects/prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39/ads/ad-accounts/oauth-url \
      -H "Authorization: Bearer lp_..." \
      -H "Content-Type: application/json" \
      -d '{
        "platform": "meta_ads",
        "returnUrl": "https://app.example.com/connect/meta/complete",
        "state": "sess_9f2a4d1c"
      }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const res = await fetch(
      `https://api.layers.com/v1/projects/${projectId}/ads/ad-accounts/oauth-url`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          platform: "meta_ads",
          returnUrl: "https://app.example.com/connect/meta/complete",
          state: crypto.randomUUID(),
        }),
      },
    );
    const { url, expiresAt } = await res.json();
    // Redirect the user to `url`.
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import httpx, uuid

    r = httpx.post(
        f"https://api.layers.com/v1/projects/{project_id}/ads/ad-accounts/oauth-url",
        headers={"Authorization": f"Bearer {api_key}"},
        json={
            "platform": "meta_ads",
            "returnUrl": "https://app.example.com/connect/meta/complete",
            "state": str(uuid.uuid4()),
        },
    )
    payload = r.json()
    ```
  </Tab>
</Tabs>

## Response [#response]

<Response status="200" description="OK - URL created.">
  ```json
  {
    "url": "https://www.facebook.com/v19.0/dialog/oauth?client_id=...&redirect_uri=...&state=...",
    "expiresAt": "2026-04-18T19:32:00Z",
    "platform": "meta_ads"
  }
  ```
</Response>

<Response status="403" description="returnUrl host is not on this API key's return-URL allowlist.">
  ```json
  {
    "error": {
      "code": "RETURN_URL_NOT_ALLOWED",
      "message": "Return URL is not on this key's allowlist. Contact your Layers account manager to add this domain to the key's allowed return URLs.",
      "requestId": "req_...",
      "details": {
        "returnUrl": "https://app.example.com/connect/meta/complete",
        "host": "app.example.com"
      }
    }
  }
  ```
</Response>

<Response status="422" description="Validation - returnUrl is not a parseable absolute URL, platform missing or unsupported.">
  ```json
  {
    "error": {
      "code": "VALIDATION",
      "message": "Invalid oauth-url body.",
      "requestId": "req_..."
    }
  }
  ```
</Response>

<Response status="404" description="Project does not exist in your organization." />

## Redirect flow [#redirect-flow]

1. Your user clicks "Connect Meta Ads" in your UI.
2. You call this endpoint and receive `url`.
3. You redirect the user to `url`.
4. The user grants access on the platform.
5. The platform redirects to a Layers-hosted callback (`/v1/ads/oauth-callback/{platform}`).
6. Layers completes the token exchange and redirects the browser to your `returnUrl` (with `?layers_oauth_error=<code>` only on failure - the success path redirects cleanly).
7. Poll [`GET /v1/projects/:projectId/ads/ad-accounts?platforms={platform}`](/docs/api/reference/ads/list-ad-accounts) until the account appears with `tokenStatus: "valid"`.

Your `state` is kept for callback correlation but is not echoed in the redirect query string.

## Scope Layers requests [#scope-layers-requests]

| Platform     | Scopes                                                                                     |
| ------------ | ------------------------------------------------------------------------------------------ |
| `meta_ads`   | `ads_management`, `ads_read`, `business_management`, `pages_manage_ads`, `pages_show_list` |
| `tiktok_ads` | `user.info.basic`, `advertiser.list`, `ad.read`, `ad.write`                                |
| `apple_ads`  | `campaigns:read`, `campaigns:write`                                                        |

If the user denies any required scope, the redirect still fires and no ad account is connected. You will see no new account on poll - that is your signal the user rejected.

## Notes [#notes]

* `url` is single-use and short-lived. Create a fresh one if the user abandons and comes back later.
* Reconnecting an expired account uses the same endpoint and updates the existing connection rather than creating a new one.
* `returnUrl` host must be on the API key's return-URL allowlist. Contact your Layers account manager to add a new domain — partners cannot self-serve the allowlist today. The legacy field name `redirectUrl` is accepted for one release cycle as a deprecated alias; new integrations should send `returnUrl`.
* There is no "connect and immediately use" shortcut. Wait for `tokenStatus: "valid"` before making campaign/ads calls against the new ad account.

## See also [#see-also]

* [`GET /v1/projects/:projectId/ads/ad-accounts`](/docs/api/reference/ads/list-ad-accounts) - poll for the new account
* [Getting started](/docs/api/getting-started) - the canonical partner flow (project → influencer → content → schedule)
* [Authentication](/docs/api/getting-started/authentication) - Partner keys, redirect origins, scopes
