# REST API

> Read projects, tracked keywords, groups, site totals, breakdowns, pages, queries, alerts and notes as JSON with a workspace API key.

Page: https://serplyze.com/docs/rest-api

The REST API returns the data you see in Serplyze as JSON, so you can feed a dashboard, a spreadsheet script or your own tools. It is read-only: every endpoint is a `GET`, and a key cannot change anything in the workspace.

For Google Sheets without any code, use [live CSV links](https://serplyze.com/docs/google-sheets.md) instead.

> **Plan:** The API needs a paid plan. A key that belongs to a workspace on Free answers every request with `403 plan_required` until the workspace is upgraded again. See [Plans and billing](https://serplyze.com/docs/plans-and-billing.md).

## Base URL

```text
https://serplyze.com/api/v1
```

All paths below are relative to this base. Responses are JSON and are sent with `Cache-Control: no-store`.

## Authentication

Send the key in the `Authorization` header as a bearer token. There is no other way to pass it: query-string keys are not read.

```bash
curl https://serplyze.com/api/v1/projects \
  -H "Authorization: Bearer ord_live_your_key"
```

- A key starts with `ord_live_` followed by 32 letters and digits.
- A key belongs to one workspace and reads every project in it, whoever created the key.
- A missing key, a malformed key or a revoked key gets `401 unauthorized` with a `WWW-Authenticate: Bearer` header.

## Create and revoke keys

1. Open **Settings → API and Sheets**.
2. Select **Create API key**, give it a name that says what it is for, and select **Create key**.
3. Copy the key from the **Copy your API key** dialog. This is the only time it is shown: Serplyze stores a hash of the key and its first characters, not the key itself.

- Only workspace owners and admins can create or revoke keys. Other members see the list without the controls.
- The list shows each key's name, its first characters, when it was created and when it was last used.
- To revoke, select **Revoke** next to the key and confirm with **Revoke key**. The key stops working at once and cannot be restored.
- A workspace can have up to 20 active keys.

## Rate limits

Each key may make 120 requests a minute. Every authenticated response carries three headers:

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Requests allowed per minute (120). |
| `X-RateLimit-Remaining` | Requests left in the current minute. |
| `X-RateLimit-Reset` | Seconds until the current minute window ends. |

Over the limit, the API answers `429` with code `rate_limited` and a `Retry-After` header holding the number of seconds to wait.

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit of 120 requests a minute reached."
  }
}
```

> **Limit:** Requests without a valid key are limited separately: after 30 failed requests in a minute from one client address, further failed requests get `429` instead of `401`.

## Errors

Every error has the same shape: an `error` object with a machine-readable `code` and a `message` you can show to a person.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "`period` must be days (e.g. 28d, up to 486d) or months (e.g. 3m, up to 16m)."
  }
}
```

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_request` | A parameter is missing, malformed or out of range. The message names the parameter. |
| 401 | `unauthorized` | No key, or the key is not valid or was revoked. |
| 403 | `plan_required` | The workspace is on Free. |
| 404 | `not_found` | Unknown project or group id, or a project that has not finished setup. |
| 409 | `google_access_lost` | Pages and queries only: Google access to the property was revoked. Connect Search Console again in the app. |
| 429 | `rate_limited` | Too many requests. Wait for `Retry-After` seconds. |
| 500 | `server_error` | Something failed on our side. Try again later. |
| 503 | `unavailable` | Pages and queries only: Search Console could not be reached. Sent with `Retry-After: 60`. |

## Ids

Project, group, page group and keyword ids are the short ids you see in app URLs: five letters and digits. Alert and note ids are UUIDs. Get project ids from `GET /projects`.

## Date ranges

The keywords, site, breakdowns, pages and queries endpoints take the same range parameters. Ranges are inclusive.

| Parameter | Type | Default | Notes |
| --- | --- | --- | --- |
| `to` | date `YYYY-MM-DD` | The project's last synced day (`syncedThrough`), or yesterday (UTC) before the first sync | Last day of the range. |
| `from` | date `YYYY-MM-DD` | 27 days before `to` (a 28-day range) | First day of the range. Must not be after `to`. |
| `period` | `1d` to `486d`, or `1m` to `16m` | none | A relative range that ends at `to`, in days or calendar months. Use it instead of `from`; sending both is a `400`. |

- A range may span at most 486 days (about 16 months, which is what Search Console keeps).
- Keyword data is limited to the keyword history of your plan. If `from` is earlier, it is moved to the first available day and the response says `"clamped": true`. If the whole range is earlier, you get a `400`.
- Site totals, breakdowns, pages and queries are not limited by plan history.
- `provisionalAfter` in a response is the last final day. Later days are still being revised by Google and can change.

## Pagination

List endpoints take `limit` and `offset` and return a `paging` object with `total`, `limit` and `offset`. Ask for the next page by adding `limit` to `offset` until `offset` reaches `total`.

| Endpoint | Default `limit` | Maximum `limit` | `offset` |
| --- | --- | --- | --- |
| `/projects/:id/keywords` | 1000 | 5000 | yes |
| `/projects/:id/pages`, `/projects/:id/queries` | 1000 | 25000 | yes |
| `/projects/:id/alerts` | 100 | 500 | no |

The other endpoints return everything in one response.

## Metrics

- `clicks` and `impressions` are whole numbers.
- `ctr` is a ratio from 0 to 1 with four decimals (`0.0412` is 4.12%). It is `null` when there are no impressions.
- `position` is the average position weighted by impressions, with one decimal. It is `null` when there are no impressions.

## GET /projects

The projects of the workspace, oldest first. Projects that have not finished setup are not listed. No parameters.

```json
{
  "projects": [
    {
      "id": "Ab12C",
      "domain": "example.com",
      "property": "sc-domain:example.com",
      "country": "deu",
      "language": "de",
      "syncStatus": "ok",
      "lastSyncedAt": "2026-03-14T05:12:44.000Z",
      "syncedThrough": "2026-03-12",
      "keywordHistoryFrom": "2025-09-13",
      "historyBackfilling": false,
      "trackedKeywords": 412
    }
  ]
}
```

- `country` is the project's country filter as an ISO 3166-1 alpha-3 code in lower case, or `null` for all countries. `language` is a two-letter code or `null`.
- `syncStatus` is one of `pending`, `ok`, `warning`, `failed`.
- `keywordHistoryFrom` is the first day of keyword data this workspace's plan can read. `historyBackfilling` is `true` while older days inside that window are still loading.
- `trackedKeywords` counts keywords that are not archived.

## GET /projects/:id/keywords

Tracked keywords with their totals over a date range, sorted by query text.

| Parameter | Type | Default | Allowed values |
| --- | --- | --- | --- |
| `from`, `to`, `period` | see Date ranges | last 28 days | Clamped to the plan's keyword history. |
| `device` | string | `all` | `all`, `desktop`, `mobile`, `tablet` |
| `daily` | flag | off | `1` or `true` adds a `daily` array to every keyword. |
| `group` | group id | none | Only keywords of this group: the members of a manual group, everything below a folder, or the matches of a rule group evaluated on the requested range. Unknown id: `404`. |
| `limit` | integer | 1000 | 1 to 5000 |
| `offset` | integer | 0 | 0 or more |

```bash
curl "https://serplyze.com/api/v1/projects/Ab12C/keywords?period=7d&device=mobile&daily=1&limit=1" \
  -H "Authorization: Bearer ord_live_your_key"
```

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "range": {
    "from": "2026-03-06",
    "to": "2026-03-12",
    "device": "mobile",
    "clamped": false,
    "provisionalAfter": "2026-03-10"
  },
  "paging": { "total": 412, "limit": 1, "offset": 0 },
  "keywords": [
    {
      "id": "k7Qp2",
      "query": "running shoes",
      "source": "manual",
      "trackedSince": "2025-11-02T09:30:00.000Z",
      "intent": "commercial",
      "groupIds": ["Gr4Zx"],
      "metrics": { "clicks": 84, "impressions": 2310, "ctr": 0.0364, "position": 6.2 },
      "daily": [
        { "date": "2026-03-06", "clicks": 11, "impressions": 320, "ctr": 0.0344, "position": 6.4 },
        { "date": "2026-03-07", "clicks": 0, "impressions": 0, "ctr": null, "position": null }
      ]
    }
  ]
}
```

- `source` says how the keyword was added: `suggested`, `manual` or `import`.
- `intent` is an estimate from the query text: `informational`, `commercial`, `transactional`, `navigational` or `unclassified`.
- `groupIds` lists the manual groups the keyword is a member of. Rule groups are not listed here; use the `group` parameter to resolve them.
- A keyword with no data in the range has zero clicks and impressions and `null` for `ctr` and `position`.
- `daily` has one entry per day the keyword has stored data for, in date order. Days with no impressions are included as zeros.

## GET /projects/:id/groups

The keyword groups and folders of a project, in the order of the rank tracker. No parameters.

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "groups": [
    { "id": "Fo1dR", "name": "Shoes", "kind": "folder", "parentId": null, "position": 0, "pinned": false, "rule": null, "keywordIds": null },
    { "id": "Gr4Zx", "name": "Running", "kind": "manual", "parentId": "Fo1dR", "position": 1, "pinned": true, "rule": null, "keywordIds": ["k7Qp2", "m3Lw9"] },
    {
      "id": "Ru1e5",
      "name": "Page one, contains trail",
      "kind": "rule",
      "parentId": "Fo1dR",
      "position": 2,
      "pinned": false,
      "rule": {
        "match": "all",
        "conditions": [
          { "field": "query", "op": "contains", "value": "trail" },
          { "field": "position", "op": "lte", "value": 10 }
        ]
      },
      "keywordIds": null
    }
  ]
}
```

- `kind` is `folder`, `manual` or `rule`. `parentId` is the id of the folder the group sits in, or `null`.
- `keywordIds` is filled for manual groups only (archived keywords are left out). For a rule group, call the keywords endpoint with `group` set to its id.
- `rule` is the rule definition of a rule group and `null` otherwise.

## GET /projects/:id/page-groups

The page groups (content groups) of a project. No parameters. The response holds the definitions, not the member pages: match them against the pages list yourself.

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "pageGroups": [
    {
      "id": "Pg7Ty",
      "name": "Blog",
      "position": 0,
      "rule": { "match": "any", "conditions": [{ "op": "starts_with", "value": "/blog/" }] },
      "pinnedUrls": ["/guides/size-chart"]
    }
  ]
}
```

## GET /projects/:id/site

Clicks, impressions, CTR and average position of the whole property per day, plus the totals of the range. This covers every query, including the ones Search Console anonymises, and is not limited by plan history.

| Parameter | Type | Default | Allowed values |
| --- | --- | --- | --- |
| `from`, `to`, `period` | see Date ranges | last 28 days |  |
| `device` | string | `all` | `all`, `desktop`, `mobile`, `tablet` |
| `country` | string | none | Three-letter country code (ISO 3166-1 alpha-3, for example `deu`). Cannot be combined with a `device` other than `all`. |

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "range": { "from": "2026-03-11", "to": "2026-03-12", "device": "all", "country": null, "provisionalAfter": "2026-03-10" },
  "totals": { "clicks": 1930, "impressions": 61200, "ctr": 0.0315, "position": 14.8 },
  "daily": [
    { "date": "2026-03-11", "clicks": 980, "impressions": 30900, "ctr": 0.0317, "position": 14.6 },
    { "date": "2026-03-12", "clicks": 950, "impressions": 30300, "ctr": 0.0314, "position": 15.0 }
  ]
}
```

- `daily` stops at the last synced day. A stored day without data is returned as zeros; days that are not synced yet are left out.
- With `country`, `range` also has `countryMonthlyThrough`: the last day that is stored as monthly totals (or `null`). Days up to it are the month's total spread evenly over its days, and only the project's top 20 countries have data there.

## GET /projects/:id/breakdowns

Totals over a range split by country, device and search appearance, and the brand / non-brand split. Parameters: `from`, `to`, `period` (see Date ranges).

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "range": {
    "from": "2026-02-13",
    "to": "2026-03-12",
    "availableFrom": "2024-11-20",
    "availableThrough": "2026-03-12",
    "provisionalAfter": "2026-03-10",
    "countryMonthlyThrough": "2025-12-31"
  },
  "countries": [
    { "country": "deu", "clicks": 18400, "impressions": 512000, "ctr": 0.0359, "position": 12.1 },
    { "country": "aut", "clicks": 2100, "impressions": 70300, "ctr": 0.0299, "position": 13.4 }
  ],
  "devices": [
    { "device": "mobile", "clicks": 14900, "impressions": 431000, "ctr": 0.0346, "position": 11.8 },
    { "device": "desktop", "clicks": 6800, "impressions": 189000, "ctr": 0.036, "position": 13.9 }
  ],
  "appearances": [
    { "appearance": "PRODUCT_SNIPPETS", "clicks": 3100, "impressions": 98000, "ctr": 0.0316, "position": 9.7 }
  ],
  "brand": {
    "brand": { "clicks": 5200, "impressions": 21000, "ctr": 0.2476, "position": 1.4 },
    "nonBrand": { "clicks": 12800, "impressions": 455000, "ctr": 0.0281, "position": 13.2 },
    "anonymous": { "clicks": 4100, "impressions": 151000 }
  }
}
```

- `availableFrom` and `availableThrough` are the days the breakdowns cover for this project. Both are `null` before the first collection.
- Countries and devices are sorted by clicks, appearances by impressions. Rows without clicks and impressions are left out.
- Search appearances overlap, because one result can carry several. They do not add up to the site total.
- `brand` uses the project's brand terms. `anonymous` is the site total minus brand and non-brand: the queries Search Console hides.
- Countries up to `countryMonthlyThrough` are monthly totals: the top 20 countries plus a row with `country` set to `other` for the rest. A range that covers part of such a month gets that month spread evenly over its days.

## GET /projects/:id/pages

The pages of the property with their totals over a range, most clicks first. This is the same list as the Pages tab of the [Traffic](https://serplyze.com/docs/traffic.md) screen.

| Parameter | Type | Default | Allowed values |
| --- | --- | --- | --- |
| `from`, `to`, `period` | see Date ranges | last 28 days | `to` must be before today (UTC). |
| `country` | string | none | Three-letter country code, for example `deu`. |
| `limit` | integer | 1000 | 1 to 25000 |
| `offset` | integer | 0 | 0 or more |

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "range": { "from": "2026-02-13", "to": "2026-03-12", "country": null },
  "source": { "kind": "gsc", "fetchedAt": "2026-03-14T06:02:11.000Z", "truncated": false, "capped": false },
  "paging": { "total": 3184, "limit": 2, "offset": 0 },
  "pages": [
    { "url": "https://example.com/running-shoes", "clicks": 1420, "impressions": 38900, "ctr": 0.0365, "position": 7.3 },
    { "url": "https://example.com/blog/trail-guide", "clicks": 960, "impressions": 41200, "ctr": 0.0233, "position": 11.9 }
  ]
}
```

- `source.kind` is `gsc` (Search Console API) or `bigquery` when the project has a working [BigQuery connection](https://serplyze.com/docs/bigquery.md). `fetchedAt` is when the list was fetched from Google.
- `source.truncated` is `true` when Google had more rows than the list holds.
- Lists are fetched from Google on demand and then cached, shared with the app. The first request for a range can take up to a minute on a large site; repeat requests are fast.

> **Limit:** From the Search Console API a list holds up to 25,000 pages or 50,000 queries per range. With a working BigQuery connection it holds up to 100,000 rows.

## GET /projects/:id/queries

Every query of the property with its totals over a range, tracked or not, most clicks first. Parameters, `source`, `paging` and limits are the same as for pages.

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "range": { "from": "2026-02-13", "to": "2026-03-12", "country": "deu" },
  "source": { "kind": "gsc", "fetchedAt": "2026-03-14T06:02:11.000Z", "truncated": false, "capped": false },
  "paging": { "total": 18250, "limit": 2, "offset": 0 },
  "queries": [
    { "query": "example shoes", "clicks": 2210, "impressions": 6400, "ctr": 0.3453, "position": 1.2, "branded": true, "trackedKeywordId": null },
    { "query": "running shoes", "clicks": 340, "impressions": 9800, "ctr": 0.0347, "position": 6.1, "branded": false, "trackedKeywordId": "k7Qp2" }
  ]
}
```

- `branded` is `true` when the query matches the project's brand terms.
- `trackedKeywordId` is the id of the tracked keyword for this query, or `null` when you do not track it.

## GET /projects/:id/alerts

The alerts of a project, newest first: the built-in checks and your own alert rules, with the same title and text as the [Alerts](https://serplyze.com/docs/alerts.md) feed.

| Parameter | Type | Default | Allowed values |
| --- | --- | --- | --- |
| `status` | string | `all` | `open`, `resolved`, `all` |
| `since` | date `YYYY-MM-DD` | none | Only alerts created on or after this day (UTC). |
| `limit` | integer | 100 | 1 to 500 |

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "alerts": [
    {
      "id": "7b0c2f8e-4a1d-4c3b-9e52-1f6a8d3b5c70",
      "type": "keyword_drop",
      "kind": "Rank drop",
      "severity": "warning",
      "title": "“running shoes” dropped from #4.2 to #9.8",
      "body": "Average position, 7 days to Mar 10, compared with the 7 days before (−5.6 positions).",
      "date": "2026-03-10",
      "keywordId": "k7Qp2",
      "url": "https://serplyze.com/Ab12C?kw=k7Qp2",
      "createdAt": "2026-03-13T05:14:02.000Z",
      "resolvedAt": null
    }
  ]
}
```

- `type` is the machine name of the check, for example `keyword_drop`, `keyword_lost`, `traffic_drop`, `rule`, `url_mismatch` or `url_changed`. `kind` is its label in the app.
- `severity` is `info`, `warning` or `critical`.
- `date` is the day the alert is about, when the alert names one. `keywordId` is set for keyword alerts. `url` opens the alert's subject in the app.
- `resolvedAt` is `null` while the alert is open.

## GET /projects/:id/notes

The chart [notes](https://serplyze.com/docs/google-updates-and-notes.md) that apply to a project, oldest first: workspace notes, project notes and notes on a group, a keyword or a page.

| Parameter | Type | Default | Allowed values |
| --- | --- | --- | --- |
| `from` | date `YYYY-MM-DD` | none | Keep notes (and note ranges) that end on or after this day. |
| `to` | date `YYYY-MM-DD` | none | Keep notes that start on or before this day. `from` must not be after `to`. |
| `clientVisible` | boolean | all notes | `true` returns only notes marked as visible to clients, as reports show them. `false` returns all notes. |

```json
{
  "project": { "id": "Ab12C", "domain": "example.com" },
  "notes": [
    {
      "id": "c1d4e9a2-6b7f-4e0a-8c15-2a9f3b7d6e41",
      "scope": "keyword",
      "date": "2026-02-20",
      "endDate": null,
      "category": "content",
      "text": "Rewrote the buying guide intro",
      "clientVisible": true,
      "author": "Sam Example",
      "group": null,
      "keyword": { "id": "k7Qp2", "query": "running shoes" },
      "page": null,
      "createdAt": "2026-02-20T10:02:31.000Z",
      "updatedAt": "2026-02-20T10:02:31.000Z"
    }
  ]
}
```

- `scope` is `workspace`, `project`, `group`, `keyword` or `page`. `group` and `keyword` are objects with an id and a name or query; `page` is the page URL. The ones that do not apply are `null`.
- `endDate` is the last day of a range note and `null` for a single day.
- `category` is one of `launch`, `content`, `technical`, `migration`, `campaign`, `other`.

## What the API does not do

> **Limit:** The API is read-only and has no webhooks. It does not add keywords, edit groups, create notes or resolve alerts, and it has no endpoints for reports, backlinks, content findings, forecasts or AI search data. Use [exports](https://serplyze.com/docs/exports.md) for those screens.

## Read next

- [Google Sheets](https://serplyze.com/docs/google-sheets.md): Pull keywords, groups, site totals, pages or queries of a project into Google Sheets with a live CSV link and IMPORTDATA.
- [BigQuery](https://serplyze.com/docs/bigquery.md): Connect a Search Console bulk export in BigQuery so the Traffic lists read every query and page from it instead of the API.
- [Exports](https://serplyze.com/docs/exports.md): Download any table, list or chart as CSV or Excel, copy it for Google Sheets, and save reports as PDF. What each export contains and its limits.
- [How the data works](https://serplyze.com/docs/how-the-data-works.md): Where the numbers come from, what average position means, how fresh the data is and how much history you get.
