> ## 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.

# Async operations and webhooks

> Know when a Content Editor, article, score, or optimization is done, by polling or by webhook.

Background work continues after the request returns; poll the endpoint in the table below or receive a webhook. Polling needs only your API key. Webhooks need an HTTPS endpoint that support registers for your organization ([Set up a webhook](#set-up-a-webhook)).

## What runs in the background

Six operations start work that outlives the response; the table says what each request answers, where to poll, and which states are final.

| Work | Started by | The request answers | Poll | Final states |
| - | - | - | - | - |
| Content Editor initialization | [Create Content Editor](/api/v2/content-editors/create-content-editor) | `201` with the editor | `state` on [Get Content Editor](/api/v2/content-editors/get-content-editor) | `completed` or `failed` |
| AI Article generation | [Create AI Article](/api/v2/ai-articles/create-ai-article) | `201` with the article | `state` on [Get AI Article](/api/v2/ai-articles/get-ai-article) | `completed` or `failed` |
| Auto-Optimize job | [Start Auto-Optimize](/api/v2/auto-optimize/start-auto-optimize) | `201` with the job | `state` on [Get Auto-Optimize job](/api/v2/auto-optimize/get-auto-optimize-job) | `completed` or `failed` |
| Score recalculation | [Update Content Editor content](/api/v2/content-editors/update-content-editor-content) | `204` with no body | `status` and `calculated_at` on the two score endpoints ([below](#recognizing-a-fresh-score)) | `ready`, or `error` for the AI Search score |
| Competitor loading | [Load more competitors](/api/v2/seo-guidelines/load-more-competitors) | `202` with `{"state":"executing"}` | `load_more_status` on [Get SEO Guidelines](/api/v2/seo-guidelines/get-seo-guidelines) | `idle` |
| Outline regeneration | [Retry outline generation](/api/v2/outline/retry-failed-content-editor-outline-generation) | `202` with `{"state":"executing"}` | `outline.status` on [Get Content Editor](/api/v2/content-editors/get-content-editor) | `completed` or `failed` |

> [!WARNING]
>
> Every endpoint under `/content_editors/{content_editor_id}/` answers `409`
> with reason `conflict` and the message `Content editor must be in completed
>     state` until the editor is `completed`; that includes creating an AI Article.
> [Errors](/api/errors) documents the envelope.

### Content Editor states

A `failed` editor has no retry call; read `error.reason` and create a new one.

| State | Meaning |
| - | - |
| `scheduled` | The editor is queued. The create response usually shows this state; it can already show `executing`. |
| `executing` | Surfer analyzes the search results and builds the guidelines. |
| `completed` | Guidelines, content, scores, articles, and optimization are available. |
| `failed` | Initialization failed. `error` carries a `reason` and a `message`; its `details` list is empty. |

### AI Article states

An article pauses in `waiting_for_user_input` only when you created it with `manual_outline: true`.

| State | Meaning |
| - | - |
| `new` | The article is created and generation has not started. |
| `generating_outline` | Surfer builds the outline. |
| `waiting_for_user_input` | The outline is ready for your edits. Submit it with [Submit edited AI Article outline](/api/v2/ai-articles/submit-edited-ai-article-outline) to continue; nothing happens until you do. |
| `writing` | Surfer writes the article. |
| `completed` | Generation finished. |
| `failed` | Generation failed. This state is final. |

### Auto-Optimize states

A completed job also carries a `result`, so check it before you read the content again.

| State | Meaning |
| - | - |
| `executing` | The job rewrites the document. |
| `completed` | The job finished. `result` is `optimized` when the document changed and `nothing_to_optimize` when it did not. |
| `failed` | The job failed, or a newer job on the same editor replaced it before it finished. |

## Recognizing a fresh score

A score is fresh when `status` is `ready` and `calculated_at` is later than the value you read before the update.

> [!WARNING]
>
> Give a polling loop a deadline: when the SEO recalculation fails, Surfer sends
> `content_editor.seo_score.failed` but `status` stays `calculating`.
> [Troubleshooting](/api/troubleshooting#the-seo-score-never-leaves-calculating)
> covers the recovery.

| Score | Endpoint | `status` values | While recalculating |
| - | - | - | - |
| SEO | [Get SEO Guidelines score](/api/v2/seo-guidelines/get-seo-guidelines-score) | `calculating`, `ready` | The previous `score` and `calculated_at` stay in the response. |
| AI Search | [Get AI Search score](/api/v2/ai-search/get-ai-search-score) | `loading`, `calculating`, `ready`, `error`, `unavailable` | The previous `score` stays while `calculating`. `score` is `null` for `loading`, `error`, and `unavailable`. |

After [Update Content Editor content](/api/v2/content-editors/update-content-editor-content) returns `204`, the SEO `status` flips to `calculating` at once. A `ready` AI Search score flips too, unless the editor's AI Search guidelines have not finished generating; then that score stays as it was and only the SEO score recalculates. `calculated_at` is `null` until the first calculation completes.

### Combined Content Score

The editor's `content_score` object on [Get Content Editor](/api/v2/content-editors/get-content-editor) carries `seo`, `ai_search`, and `total`. `total` is the rounded average of the two, capped at 100, when both exist, and equals `seo` when the AI Search score is missing.

## Set up a webhook

One HTTPS URL per organization receives every event; support registers it and gives you the verification key.

**1. Serve an HTTPS endpoint**

Serve an HTTPS URL that accepts `POST` requests with a JSON body.

**2. Answer with a 2xx status within 30 seconds**

Queue the body and answer before you process it. An answer after 30 seconds
counts as a failed delivery ([Delivery](#delivery)).

**3. Ask support to register the URL**

Send the URL through the same support channel as your API key
([Authentication](/api/authentication#get-your-api-key)). Support registers
it for your organization and replies with the verification key.

**4. Store the verification key**

Keep it where your endpoint can read it, for example in an environment
variable.

**5. Reject deliveries with a different key**

Compare the `verification-key` header of each request with the stored key,
and drop bodies whose key differs.

**6. Create a test Content Editor**

This spends one Content Editor credit ([Rate limits and
credits](/api/rate-limits)). Call [Create Content
Editor](/api/v2/content-editors/create-content-editor) with any keyword.

**7. Wait for the first event**

`content_editor.initialization.completed` arrives when initialization
finishes and carries the editor's `id` as `draft_id`.

To change the URL, ask support again. The new URL replaces the old one and comes with a new verification key, so update both in your endpoint together.

## Delivery

Each event is one `POST` with a JSON body and the verification key in a header. Your response status decides whether Surfer marks the delivery done, retries it, or stops.

A delivery looks like this, here for `content_editor.initialization.completed`:

```http theme={"system"}
POST /surfer/webhook HTTP/1.1
Content-Type: application/json
verification-key: <your key>

{
  "id": 8231907,
  "type": "content_editor.initialization.completed",
  "timestamp": "2026-09-11T10:15:30Z",
  "payload": {
    "draft_id": 123456,
    "permalink_hash": "k3d9f2a1c7b4e8f0a6c2d5b9e1f4a7c3"
  }
}
```

| Field | Meaning |
| - | - |
| `id` | The delivery id. It stays the same across retries of one event, so deduplicate on it. |
| `type` | The event name from the [catalog](#event-catalog). |
| `timestamp` | When the event happened, ISO 8601 in UTC. |
| `payload` | The fields the catalog lists for that event. |

| Your response | What Surfer does |
| - | - |
| Any `2xx` | Marks the delivery done. |
| `408`, `429`, any `5xx`, or no response within 30 seconds | Retries, five attempts in total, with growing delays of about 17, 19, 23, and 31 seconds, each plus some jitter. |
| Any other status | Stops delivering that event. It is not retried. |

Deliveries can arrive out of order: a retried event lands after events that came later. If your endpoint processed a delivery but answered late, the retry brings the same `id` again.

A failed SEO recalculation runs a second time, so one content update can send `content_editor.seo_score.failed` twice, or once before `content_editor.seo_score.calculated`. Keep the polling endpoints above as the way to re-sync after a gap.

## Which work sends events

Most events are sent only for work started through the API; work started in the Surfer app sends none, with one exception.

| Events | Sent for |
| - | - |
| `content_editor.initialization.*` | Every Content Editor in your organization, however it was created, except editors that already have an AI Article attached when initialization finishes. |
| `content_editor.ai_search_score.*` | Editors created through the API, once at initialization, and each recalculation in the next row, when the AI Search guidelines have finished generating. |
| `content_editor.seo_score.*` and `content_editor.content_score.recalculated` | Recalculations after a content update through the API, an API-started Auto-Optimize job that changed the document, or an API-started AI Article that finished. Not sent at creation, even when `import_content_url` is set. |
| `content_editor.ai_article.*` | AI Articles created through the API. |
| `content_editor.auto_optimize.*` | Auto-Optimize jobs started through the API. |
| `content_editor.seo_guidelines.competitors.load_more.*` | Competitor loading started through the API. |
| `content_editor.outline.*` | Outline regeneration started through the API. |

## Event catalog

Every event names the Content Editor: `draft_id` is the editor's `id`, and `permalink_hash` is the `hash` of its edit link ([List Content Editor permalinks](/api/v2/permalinks/list-content-editor-permalinks)).

| Event | When it fires | Payload fields |
| - | - | - |
| `content_editor.initialization.completed` | Initialization finished and the editor is `completed`. | `draft_id`, `permalink_hash` |
| `content_editor.initialization.failed` | Initialization failed and the editor is `failed`. | `draft_id`, `permalink_hash`, `reason` |
| `content_editor.seo_score.calculated` | The SEO score was recalculated. | `draft_id`, `permalink_hash`, `score` |
| `content_editor.seo_score.failed` | The SEO recalculation failed. | `draft_id`, `permalink_hash`, `reason` |
| `content_editor.ai_search_score.calculated` | The AI Search score was calculated. | `draft_id`, `permalink_hash`, `score` |
| `content_editor.ai_search_score.failed` | The AI Search calculation failed. | `draft_id`, `permalink_hash`, `reason` |
| `content_editor.content_score.recalculated` | Both scores settled after a recalculation. `content_score` is the new `total`. | `draft_id`, `permalink_hash`, `content_score` |
| `content_editor.ai_article.waiting_for_user_input` | The outline is ready for your edits. | `draft_id`, `permalink_hash` |
| `content_editor.ai_article.completed` | The article was written. | `draft_id`, `permalink_hash` |
| `content_editor.ai_article.failed` | Generation failed. | `draft_id`, `permalink_hash` |
| `content_editor.auto_optimize.completed` | The job finished. `result` is `optimized` or `nothing_to_optimize`. | `job_id`, `draft_id`, `permalink_hash`, `result` |
| `content_editor.auto_optimize.failed` | The job failed. | `job_id`, `draft_id`, `permalink_hash` |
| `content_editor.auto_optimize.cancelled` | A newer job on the same editor replaced this one before it finished. | `job_id`, `draft_id`, `permalink_hash` |
| `content_editor.seo_guidelines.competitors.load_more.completed` | The extra competitors were loaded and the guidelines updated. | `draft_id`, `seo_guidelines_id` |
| `content_editor.seo_guidelines.competitors.load_more.failed` | Loading failed. | `draft_id`, `seo_guidelines_id`, `reason` |
| `content_editor.outline.completed` | The outline was regenerated. | `draft_id`, `permalink_hash` |
| `content_editor.outline.failed` | Regeneration failed. | `draft_id`, `permalink_hash` |

The AI Article events carry no article id. Use `draft_id` to [list the editor's articles](/api/v2/ai-articles/list-ai-articles-for-content-editor) and read the newest one.

## Which endpoint triggers which events

Each operation sends its own events first; when the document changed, the score events follow.

| Endpoint | Events, in order |
| - | - |
| [Create Content Editor](/api/v2/content-editors/create-content-editor) | `content_editor.initialization.completed` or `content_editor.initialization.failed`, then `content_editor.ai_search_score.calculated` or `content_editor.ai_search_score.failed`. No AI Search score event is sent when that score ends `unavailable`. |
| [Update Content Editor content](/api/v2/content-editors/update-content-editor-content) | `content_editor.seo_score.calculated` or `content_editor.seo_score.failed`, and, when the AI Search guidelines have finished generating, `content_editor.ai_search_score.calculated` or `content_editor.ai_search_score.failed`, in either order, then `content_editor.content_score.recalculated`. |
| [Create AI Article](/api/v2/ai-articles/create-ai-article) | `content_editor.ai_article.waiting_for_user_input` when `manual_outline` is `true`, then `content_editor.ai_article.completed` or `content_editor.ai_article.failed`. A completed article is followed by `content_editor.seo_score.calculated`, then `content_editor.ai_search_score.calculated` or `content_editor.ai_search_score.failed`, then `content_editor.content_score.recalculated`. |
| [Start Auto-Optimize](/api/v2/auto-optimize/start-auto-optimize) | `content_editor.auto_optimize.cancelled` for the job it replaces, then `content_editor.auto_optimize.completed` or `content_editor.auto_optimize.failed`. A job whose `result` is `optimized` is followed by the same score events as a content update. |
| [Load more competitors](/api/v2/seo-guidelines/load-more-competitors) | `content_editor.seo_guidelines.competitors.load_more.completed` or `content_editor.seo_guidelines.competitors.load_more.failed`. |
| [Retry outline generation](/api/v2/outline/retry-failed-content-editor-outline-generation) | `content_editor.outline.completed` or `content_editor.outline.failed`. |


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