> ## Documentation Index
> Fetch the complete documentation index at: https://sorank.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Use the SORANK public API

> Authenticate, inspect quotas, create content and track public API operations.

## Which address should you use?

Sign in and manage keys in the app at `https://app.sorank.com/settings/api`. Send API requests to `https://app.sorank.com/api/public/v1`. There is no API endpoint to create or host yourself.

## Your first request, step by step

1. Open **Settings → API access** in the SORANK app and select **Create key**.
2. Give the key a name, select at least one website, select **View websites** (`profiles:read`), choose an expiry, and create it. Add **View quotas** (`quotas:read`) if you want the second request below.
3. Copy the complete key now. It is shown only once. Keep it in a secret manager or a local environment variable; the prefix shown later in settings cannot authenticate requests.
4. Choose an example below. For cURL, enter the key when prompted. For JavaScript or Go, provide it as the server process's `SORANK_API_KEY` environment variable. The HTML tab includes a local Node.js backend at `127.0.0.1:3100`; run it with the key in its environment, then open that address in a browser. Keep the key out of HTML and browser JavaScript. Add your application's authentication before using this pattern beyond a local demo.

<Tabs>
  <Tab title="cURL">
    ```sh theme={null}
    printf 'SORANK API key: '; read -rs SORANK_API_KEY; echo
    curl --fail-with-body --silent --show-error \
      -H "Authorization: Bearer $SORANK_API_KEY" \
      'https://app.sorank.com/api/public/v1/profiles'

    # Copy an authorized data[].id from the response above.
    PROFILE_ID='<profile_id>'
    curl --fail-with-body --silent --show-error \
      -H "Authorization: Bearer $SORANK_API_KEY" \
      "https://app.sorank.com/api/public/v1/profiles/$PROFILE_ID/quotas"
    unset SORANK_API_KEY
    ```
  </Tab>

  <Tab title="HTML">
    ```html index.html theme={null}
    <button id="load">Load my SORANK websites</button>
    <pre id="result"></pre>
    <script>
      document.querySelector('#load').addEventListener('click', async () => {
        const output = document.querySelector('#result');
        try {
          // Your own backend authenticates to SORANK. Keep the key out of this page.
          const response = await fetch('/api/sorank/profiles');
          if (!response.ok) throw new Error(`HTTP ${response.status}`);
          const profiles = await response.json();
          output.textContent = JSON.stringify(profiles, null, 2);
        } catch (error) {
          output.textContent = String(error);
        }
      });
    </script>
    ```

    ```js server.mjs theme={null}
    // Local demo backend. Run with SORANK_API_KEY in the environment.
    import { createServer } from 'node:http';
    import { readFile } from 'node:fs/promises';

    const key = process.env.SORANK_API_KEY;
    if (!key) throw new Error('Set SORANK_API_KEY before running this file');
    const html = await readFile(new URL('./index.html', import.meta.url));

    createServer(async (request, response) => {
      if (request.method === 'GET' && request.url === '/') {
        response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        response.end(html);
        return;
      }
      if (request.method === 'GET' && request.url === '/api/sorank/profiles') {
        try {
          const upstream = await fetch('https://app.sorank.com/api/public/v1/profiles', {
            headers: { Authorization: `Bearer ${key}` },
          });
          response.writeHead(upstream.status, {
            'Content-Type': 'application/json; charset=utf-8',
            'Cache-Control': 'no-store',
          });
          response.end(await upstream.text());
        } catch {
          response.writeHead(502).end('Upstream unavailable');
        }
        return;
      }
      response.writeHead(404).end('Not found');
    }).listen(3100, '127.0.0.1');
    // Open http://127.0.0.1:3100 after starting this server.
    ```
  </Tab>

  <Tab title="JavaScript">
    ```js profiles.mjs theme={null}
    // Run on Node.js 20+ with SORANK_API_KEY set in the process environment.
    const token = process.env.SORANK_API_KEY;
    if (!token) throw new Error('Set SORANK_API_KEY before running this file');
    const base = 'https://app.sorank.com/api/public/v1';

    async function get(path) {
      const response = await fetch(`${base}${path}`, {
        headers: { Authorization: `Bearer ${token}` },
      });
      const body = await response.json();
      if (!response.ok) throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`);
      return body;
    }

    const profiles = await get('/profiles');
    console.log(profiles);
    const profileId = profiles.data[0]?.id;
    if (profileId) console.log(await get(`/profiles/${encodeURIComponent(profileId)}/quotas`));
    ```
  </Tab>

  <Tab title="Go">
    ```go main.go theme={null}
    package main

    import (
      "encoding/json"
      "fmt"
      "io"
      "net/http"
      "os"
      "time"
    )

    func main() {
      key := os.Getenv("SORANK_API_KEY")
      if key == "" { panic("set SORANK_API_KEY before running this file") }
      client := &http.Client{Timeout: 15 * time.Second}
      get := func(path string) []byte {
        req, err := http.NewRequest(http.MethodGet, "https://app.sorank.com/api/public/v1"+path, nil)
        if err != nil { panic(err) }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil { panic(err) }
        defer resp.Body.Close()
        body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        if err != nil { panic(err) }
        if resp.StatusCode != http.StatusOK { panic(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, body)) }
        return body
      }

      body := get("/profiles")
      fmt.Println(string(body))
      var profiles struct { Data []struct { ID string `json:"id"` } `json:"data"` }
      if err := json.Unmarshal(body, &profiles); err != nil { panic(err) }
      if len(profiles.Data) > 0 {
        fmt.Println(string(get("/profiles/"+profiles.Data[0].ID+"/quotas")))
      }
    }
    ```
  </Tab>
</Tabs>

A successful response has a `data` list of the websites you authorized. Copy one `data[].id` value for calls requiring `profile_id`. If the list is empty, check the website selection on your key and your access to it. The cURL, JavaScript and Go examples also read that profile's quota when you have granted `quotas:read`.

Use the **Public API reference** in the developer navigation to find every path, required scope, request body and response schema. The first request above is read-only; it does not use account quota.

## Authenticate

Create a personal API key in **Settings → API access** for scripts, or authorize an OAuth client with PKCE S256. Restrict the key or grant to the profiles and exact scopes you need. Send it as a Bearer token. Browser cookies and Supabase tokens do not authorize this API.

For an application connecting on a user's behalf, start at `https://app.sorank.com/.well-known/oauth-protected-resource/api/public/v1` and `https://app.sorank.com/.well-known/oauth-authorization-server`. Discover the advertised authorization, token and registration endpoints. Register a public client if needed, then use authorization code with PKCE S256, the scopes you need, and the exact `resource` value `https://app.sorank.com/api/public/v1`. Send the resulting access token as Bearer. An MCP access token has a different resource and cannot be reused for REST.

## Permissions and quota

Read `getQuotas` before generation. `profiles:read`, `quotas:read`, `calendar:read`, `calendar:write`, `articles:read`, `articles:generate` and `articles:publish` cover the example below. Calendar creation also requires generation and publication scopes. One account quota applies across the app, REST and MCP; extra keys do not increase it.

The read scopes **View products** (`products:read`), **View analytics** (`analytics:read`), **View AI visibility** (`geo:read`) and **View settings** (`settings:read`) never consume quota. **Edit settings** (`settings:write`) changes the same settings as the app; grant it only when you want an integration to change them.

## Check eligibility

The API is available once you have completed onboarding in Sorank and the website has a valid subscription. An agency collaborator uses the website owner's subscription. Sorank checks this when you create a key or approve an OAuth connection, and again on every request.

* `listProfiles` returns only eligible websites.
* An unfinished onboarding returns `403 onboarding_required`.
* A website without a valid subscription returns `402 subscription_required`, with `details.reason` (`paused`, `past_due`, `incomplete` or `inactive`) when known.

Finish onboarding, or update the subscription in **Settings → Billing**. Your existing key works again on the next request.

## Read products, analytics, AI visibility and settings

These reads return what the Sorank app shows you. They never start an analysis, a synchronization or a generation.

* **Products** (`products:read`): `listProducts` pages through the product catalog with a cursor. After `409 product_catalog_cursor_stale`, restart from the first page.
* **Analytics** (`analytics:read`): Search Console performance, queries, pages, countries and devices, and Google Analytics traffic from AI assistants. `start` and `end` (`YYYY-MM-DD`) are required. Each call reads Google live and has a lower rate limit. Connect Google in Sorank first; otherwise you receive `409 gsc_not_connected` or `409 ga_not_connected`, and `403 google_access_lost` when access was lost.
* **AI visibility** (`geo:read`): GEO statistics, mentions over time, citations timeline, citing sources and the competitors cited in an analysis.
* **Settings** (`settings:read`): `getSettings` returns the website, the business brief with its `revision`, competitors, article defaults, visuals and the YouTube channel.

## Change settings

With `settings:write`, you can change the business name, brand color and market, replace the business brief, replace the competitor list, change the calendar article defaults, the image mode and covers, and connect, pause or disconnect the YouTube channel. The website URL cannot be changed through the API (`400 field_not_writable`).

Each change requires an `Idempotency-Key`. The response contains the resulting settings, a receipt and an `effects` list naming the background work the change started, as in the app: `geo_questions_regeneration_queued`, `backlink_refresh_queued`, `calendar_slots_shifted`, `image_gallery_analysis_queued`, `youtube_sync_queued` or `youtube_purge_queued`.

To replace the business brief, send the `expected_revision` read from `getSettings`. If the brief changed meanwhile, you receive `409 business_brief_revision_conflict`: read the settings again before retrying.

## Try a complete REST workflow

The following advanced example creates content and can consume quota. Replace the example IDs, future dates, idempotency keys and credential before running it. Use a key with the scopes above. Dates are local to the IANA timezone in the calendar request. Check `getCapabilities` before selecting a CMS target or category.

```sh theme={null}
# The production API is hosted by the SORANK app.
API_ORIGIN="https://app.sorank.com"
PROFILE_ID="00000000-0000-4000-8000-000000000000"
ARTICLE_ID="11111111-1111-4111-8111-111111111111"
TOKEN="<PERSONAL_API_KEY>"

curl -H "Authorization: Bearer $TOKEN" "$API_ORIGIN/api/public/v1/profiles/$PROFILE_ID/quotas"
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Idempotency-Key: 22222222-2222-4222-8222-222222222222" \
  -d '{"settings":{"timezone":"Europe/Paris","local_time":"09:30"},"start_date":"2026-11-01","end_date":"2026-12-31","subjects":[{"subject":"Sustainable travel checklist"}]}' \
  "$API_ORIGIN/api/public/v1/profiles/$PROFILE_ID/calendar"
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Idempotency-Key: 33333333-3333-4333-8333-333333333333" \
  -d '{"seeds":[{"keyword":"sustainable travel tips","title":"Practical sustainable travel"}]}' \
  "$API_ORIGIN/api/public/v1/profiles/$PROFILE_ID/article-generations"
curl -H "Authorization: Bearer $TOKEN" "$API_ORIGIN/api/public/v1/operations/<OPERATION_UUID>"
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Idempotency-Key: 44444444-4444-4444-8444-444444444444" \
  -d '{"target":"draft"}' "$API_ORIGIN/api/public/v1/profiles/$PROFILE_ID/articles/$ARTICLE_ID/publications"
```

Use the separate public API reference for every operation, request field, response and error.

## Track work and retry

Mutations require a unique `Idempotency-Key`. Keep the same key when retrying the same command. Read `data.receipt.operation_id`, then poll `getOperation` until `succeeded`, `failed` or `verification_pending`. An accepted receipt does not mean publication is complete. An article generation batch returns `202` with a receipt that is already `succeeded`; follow each article listed in `selection.accepted_ids` with `getArticle`. Reauthorization occurs on every poll, so revocation takes effect immediately.

## Handle errors

Every error has the same shape in REST and MCP: `{"error": {"code", "message", "request_id", "retryable", "details"}}`. Base your logic on `code`, not on the message. Retry only when `retryable` is `true`, after `Retry-After` when present.

* `401 invalid_credential`: invalid, expired or revoked credential.
* `403 missing_scope` or `403 onboarding_required`; `402 subscription_required` or `402 insufficient_quota`.
* `404 not_found`: the resource is missing or not accessible to this credential.
* `409`: a conflict such as `idempotency_conflict`; `429 rate_limited`: wait for `Retry-After`.
* `503 auth_state_unavailable`: access is denied until the authorization state recovers; retry later.

The public API reference lists the codes of each operation. Share `code` and `request_id` with support; never send your token.

For assistant connections, see the MCP guide.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.