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

# Requests and responses

> Build v2 requests with the URL, header, list, and retry conventions the endpoints share.

Send v2 requests to `https://app.surferseo.com/api/v2`, over HTTPS, with your key in the `API-KEY` header ([Authentication](/api/authentication)). Each generated reference page adds only what is specific to its endpoint: the request and response fields, and the filters and sort fields its list accepts.

## URLs and versions

A v2 URL names the version, the workspace, and the resource.

A workspace is the brand or site you manage in Surfer, and a Content Editor is the document Surfer builds around a main keyword and its search analysis ([Create Content Editor](/api/v2/content-editors/create-content-editor)). This URL addresses Content Editor `456` in workspace `123`:

```text theme={"system"}
https://app.surferseo.com/api/v2/workspaces/123/content_editors/456
```

v1 stays available at `https://app.surferseo.com/api/v1` and takes the same key. [Migrating from v1](/api/migrating-from-v1) covers what changed between the versions and which tools remain v1 only.

## Headers and formats

Resource endpoints take and return JSON; five operations carry an HTML or Markdown document instead, each with its own headers ([below](#document-endpoints)).

| Convention | What to send | Otherwise |
| - | - | - |
| `Accept` header | `application/json`, or no `Accept` at all | `406 not_acceptable` with the message `Accept must be one of: application/json` |
| `Content-Type` header | `application/json` on every `POST`, `PATCH`, and `PUT` that requires a request body | `415 unsupported_media_type` with the message `Content-Type must be one of: application/json` |
| Request body | Valid JSON with the fields on the endpoint's reference page | `400 validation_error`: the message `Malformed request body` for JSON that does not parse, or the detail `Request body is required` when a required body is missing |

A `POST` that takes no body, such as [Start Auto-Optimize](/api/v2/auto-optimize/start-auto-optimize), needs no `Content-Type`.

Timestamps in responses and filters are ISO 8601 in UTC, for example `2024-04-12T15:23:45Z`. Every error Surfer handles uses one JSON envelope with a `reason` and `details`; [Errors](/api/errors) documents it and every reason.

In a terminal, create a content template in workspace `123` from the fields saved in `template.json` ([Create content template](/api/v2/content-templates/create-content-template) lists them):

```bash theme={"system"}
curl -X POST "https://app.surferseo.com/api/v2/workspaces/123/content_templates" \
  -H "API-KEY: $SURFER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d @template.json
```

A `201` returns the new template as JSON.

### Document endpoints

Five operations carry the document itself, with no JSON wrapper, and the headers and the success response differ per operation.

| Operation | `Content-Type` | `Accept` | Success |
| - | - | - | - |
| [Get Content Editor content](/api/v2/content-editors/get-content-editor-content) | No body | `text/html` or `text/markdown` | `200` with the document |
| [Update Content Editor content](/api/v2/content-editors/update-content-editor-content) | `text/html` or `text/markdown` | `text/html` or `text/markdown` | `204` with no body |
| [Get Content Editor outline](/api/v2/outline/get-content-editor-outline) | No body | `text/html` or `text/markdown` | `200` with the document |
| [Get AI Article outline](/api/v2/ai-articles/get-ai-article-outline) | No body | `text/html` or `text/markdown` | `200` with the document |
| [Submit edited AI Article outline](/api/v2/ai-articles/submit-edited-ai-article-outline) | `text/html` or `text/markdown` | `application/json` | `200` with the AI Article as JSON |

A document read with no `Accept` returns HTML, and the document is always UTF-8, whatever `charset` the header carries. A `Content-Type` that is missing or names another type gets `415`.

`Accept` does not follow `Content-Type` on the two writes: [Update Content Editor content](/api/v2/content-editors/update-content-editor-content) answers `406` to `Accept: application/json`, and [Submit edited AI Article outline](/api/v2/ai-articles/submit-edited-ai-article-outline) returns the AI Article as JSON, so send `Accept: application/json` or omit `Accept`.

Two rules constrain a content update before you send it:

* The body is capped at 1,000,000 bytes; a larger one gets `413 content_too_large`.
* Surfer parses and re-serializes the document before storing it, so the stored form can differ from what you sent.

In a terminal, replace the content of Content Editor `456` in workspace `123` with the Markdown file `article.md`:

```bash theme={"system"}
curl -X PUT "https://app.surferseo.com/api/v2/workspaces/123/content_editors/456/content" \
  -H "API-KEY: $SURFER_API_KEY" \
  -H "Content-Type: text/markdown" \
  --data-binary @article.md
```

The `204` does not echo the stored form; fetch the content again to read it. The update also starts a score recalculation, and [Recognizing a fresh score](/api/async-and-webhooks#recognizing-a-fresh-score) covers how to know when it has settled.

## Workspaces in the path

Most resources are workspace-scoped: the workspace id sits in the URL path, and there is no default workspace and no header to set.

One organization can hold several workspaces, and one key reaches all of them ([what a key can reach](/api/authentication#what-a-key-can-reach)). Start with [List workspaces](/api/v2/workspaces/list-workspaces) to find their ids; only a workspace whose `state` is `active` accepts requests.

Three collections sit above any workspace; everything else lives under one:

| Path | Scope |
| - | - |
| `GET /api/v2/workspaces` | The workspaces of your organization |
| `GET /api/v2/content_editors` | Content Editors across every workspace of your organization |
| `GET /api/v2/surfer_content_templates` | Surfer Content Templates, the built-in templates, which belong to no workspace |
| `/api/v2/workspaces/{workspace_id}/...` | One workspace: its Content Editors and everything under them, its content templates, and its custom voices |

A request with a `workspace_id` Surfer cannot use gets `404 not_found`, with a detail whose `reason` is `entity_not_found` and whose `entity` is `workspace`. The causes:

* The workspace belongs to another organization.
* The workspace is not `active` or was deleted.
* The id is not a number.

The workspace check never answers `403`, so a `404` on a path you believe is right points at the id, not the key.

In a terminal, list the custom voices in workspace `123`:

```bash theme={"system"}
curl "https://app.surferseo.com/api/v2/workspaces/123/custom_voices" \
  -H "API-KEY: $SURFER_API_KEY"
```

The response is a list, the shape the next section describes.

## Lists

A list response is an object with `data`, the items returned, and `meta`, the counts and the parameters in effect; [Which lists are paginated](#which-lists-are-paginated) says which lists take the parameters below.

| Parameter | Default | Accepted values |
| - | - | - |
| `page` | `1` | A positive integer |
| `page_size` | `25` | `1` to `100`; on most lists a larger value is clamped to `100` without an error |

A `page` or `page_size` that is not a positive integer, `0` or `abc` for example, gets `400 validation_error` with the detail `field` naming the parameter and `reason` `invalid`. An empty value falls back to the default.

Fetch the second page of 50 Content Editors in workspace `123`:

```text theme={"system"}
GET /api/v2/workspaces/123/content_editors?page=2&page_size=50
```

When 156 editors match, the response carries this `meta` object:

```json theme={"system"}
{
  "total": 156,
  "page": 2,
  "page_size": 50,
  "total_pages": 4,
  "sort": "inserted_at",
  "order": "desc"
}
```

| Field | Present on | Meaning |
| - | - | - |
| `total` | Every list | Items in the whole list, after any filters |
| `page` | Paginated lists | The page returned |
| `page_size` | Paginated lists | Items per page, after clamping |
| `total_pages` | Paginated lists | `total` divided by `page_size`, rounded up |
| `sort` and `order` | Paginated lists | The ordering in effect, defaults included |
| `filters` | Lists with a filter applied | The filters in effect, keyed by name |

### Which lists are paginated

Six lists are paginated; the rest return every item, and their `meta` carries `total` plus any field the reference page documents.

| List | Paginated |
| - | - |
| [List workspaces](/api/v2/workspaces/list-workspaces) | Yes |
| [List all Content Editors](/api/v2/content-editors/list-all-content-editors) | Yes |
| [List Content Editors in workspace](/api/v2/content-editors/list-content-editors-in-workspace) | Yes |
| [List AI Articles for Content Editor](/api/v2/ai-articles/list-ai-articles-for-content-editor) | Yes |
| [List content templates](/api/v2/content-templates/list-content-templates) | Yes |
| [List custom voices](/api/v2/custom-voices/list-custom-voices) | Yes |
| [List SEO Guidelines competitors](/api/v2/seo-guidelines/list-seo-guidelines-competitors) | No |
| [List SEO Guidelines terms](/api/v2/seo-guidelines/list-seo-guidelines-terms) | No |
| [List SEO Guidelines topics and questions](/api/v2/seo-guidelines/list-seo-guidelines-topics-and-questions) | No |
| [List AI Search guidelines facts](/api/v2/ai-search/list-ai-search-guidelines-facts) | No |
| [List Content Editor permalinks](/api/v2/permalinks/list-content-editor-permalinks) | No |
| [List Surfer Content Templates](/api/v2/surfer-content-templates/list-surfer-content-templates) | No |

## The 90-day window on Content Editor lists

Both Content Editor lists cover at most 90 days of creation dates; move the window with `filter[inserted_after]` and `filter[inserted_before]`.

The window applies to [List all Content Editors](/api/v2/content-editors/list-all-content-editors) and [List Content Editors in workspace](/api/v2/content-editors/list-content-editors-in-workspace) alike, and `total` counts only the editors inside it.

| Filters sent | Window |
| - | - |
| Neither | The 90 days up to now |
| `filter[inserted_after]` only | The 90 days after it, cut off at now |
| `filter[inserted_before]` only | The 90 days before it |
| Both | That window, as sent |

Both filters take an ISO 8601 timestamp; a value without a zone is read as UTC. The window must start before it ends, end no later than now, and span at most 90 days; `filter[inserted_after]` must lie in the past even when sent alone.

A window that breaks a rule gets `400 validation_error` with the detail `field` `filter` and `reason` `invalid_date_range`; a timestamp Surfer cannot parse gets the same status with `reason` `invalid_date_format`. To read further back, move the window in 90-day steps and page inside each.

List the Content Editors created in March 2026, across every workspace:

```text theme={"system"}
GET /api/v2/content_editors?filter[inserted_after]=2026-03-01T00:00:00Z&filter[inserted_before]=2026-04-01T00:00:00Z
```

## Filtering

A filter is a query parameter of the form `filter[key]=value`, and each reference page lists the keys and values its list accepts.

| Rule | Behavior |
| - | - |
| Several values for one key | Separate them with commas to match any of them, for example `filter[state]=completed,failed` |
| Unknown key | Ignored: the list is not filtered by it and `meta.filters` omits it |
| Value outside the allowed set | `400 validation_error` with the detail `field` set to `filter[key]`, `reason` `invalid`, and the rejected `value` |

Today [List workspaces](/api/v2/workspaces/list-workspaces) filters by `type` and `state`, and the two Content Editor lists filter by `state`, `inserted_after`, and `inserted_before`; the reference pages list the values.

In a terminal, list the completed and failed Content Editors in workspace `123`, 100 per page:

```bash theme={"system"}
curl -g "https://app.surferseo.com/api/v2/workspaces/123/content_editors?filter[state]=completed,failed&page_size=100" \
  -H "API-KEY: $SURFER_API_KEY"
```

The `-g` flag stops `curl` from reading the brackets as a URL pattern. In the response, `meta.filters` is `{"state": ["completed", "failed"]}`.

## Ordering

Paginated lists take `sort` and `order`, and the default is newest first.

| Parameter | Default | Values |
| - | - | - |
| `sort` | `inserted_at` | Every paginated list accepts `inserted_at` and `updated_at`; workspaces, content templates, and custom voices add `name` |
| `order` | `desc` | `asc` or `desc` |

An unsupported `sort` or `order` gets `400 validation_error` with a detail that names the allowed values; [Validation details](/api/errors#validation-details) shows it.

List workspaces in alphabetical order:

```text theme={"system"}
GET /api/v2/workspaces?sort=name&order=asc
```

## Idempotent creates

Three creates take an `Idempotency-Key` header, and a retry with the same key after a timeout or a dropped connection creates no second resource:

* [Create Content Editor](/api/v2/content-editors/create-content-editor)
* [Create content template](/api/v2/content-templates/create-content-template)
* [Create custom voice](/api/v2/custom-voices/create-custom-voice)

The other `POST` operations, including starting an AI Article or an Auto-Optimize run, do not take the header; check the resource's state before starting again ([What runs in the background](/api/async-and-webhooks#what-runs-in-the-background)).

The key is any string you generate, a UUID for example. Surfer scopes it to your API key and keeps it for 24 hours after the first request.

Surfer stores the path, the status, and the id of the created resource, not the request body, so a retry with a different body is still a retry. Use a fresh key for every create you intend as new.

| Second request with the same key | Response |
| - | - |
| Same path, and the first request succeeded | The original status, `201`, with the resource as it is now |
| Same path, and the first request is still running | `409 conflict` with the message `A request with this idempotency key is currently being processed`; wait, then retry |
| A different path | `422 unprocessable_entity` with the message `This idempotency key was already used with a different request` |
| Same path, and the first request failed | The key was not kept, so the create runs again |
| Same path, and the resource was deleted since | `404 not_found` |

Both messages also appear on [Errors](/api/errors#reasons-and-what-to-do).

In a terminal, create a custom voice in workspace `123` from the fields saved in `voice.json` ([Create custom voice](/api/v2/custom-voices/create-custom-voice) lists them), with a retry key:

```bash theme={"system"}
curl -X POST "https://app.surferseo.com/api/v2/workspaces/123/custom_voices" \
  -H "API-KEY: $SURFER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2b0f7a9e-4c31-4a4c-9d0e-8e0f1c2a3b4d" \
  -d @voice.json
```

Run it twice, and the second run is the first row of the table: `201` with the same voice.


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