◆ Sapport for developers API reference Error catalog OpenAPI
Sections

#Articles API

Status: reading is Available; editing, approval, publishing, and generation are Planned. GET /articles and GET /articles/{id} work. PATCH, POST …/approve, and POST …/publish are not released in the public API: editing, approval, and publishing are available in the Sapport dashboard. The description of those endpoints is a design; fields and codes are preliminary. No dates are promised.

The articles API lets you read the article queue, fetch the text in the format you need, edit and approve articles, and publish them to your site through a module or hand them off to your own system to publish.

Access: articles:read to read; articles:write to edit, approve, and publish. Generation (articles:generate) has its own page. Paid: no. PII: no.

#Base URL

CellURL
RUhttps://formula-cream.pro/api/public/v1
ENhttps://wfacademy.org/api/public/v1
IDhttps://wfacademy.id/api/public/v1

For errors, pagination, idempotency, and limits, see Conventions. For keys and scopes, see Authentication. The key in every example is deliberately fake.

#Endpoints

Method and pathScopeWhat it does
GET /articlesarticles:readList articles (Available)
GET /articles/{id}articles:readAn article; content format html, markdown, or blocks (Available)
PATCH /articles/{id}articles:writeEdit fields (Planned)
POST /articles/{id}/approvearticles:writeApprove (Planned)
POST /articles/{id}/publisharticles:writePublish through a module or hand off to your system (Planned)
POST /articles/generation-jobsarticles:generateOrder generation (paid); see the description

#The article object

FieldTypeDescription
idstring (uuid)ID
titlestringTitle
slugstring | nullPage address
statusstringArticle status (see below)
localestring | nullLanguage: ru, en, id
keywordstring | nullMain search query
meta_title, meta_descriptionstring | nullSEO title and description
cover_image_urlstring | nullCover image
word_countintegerWord count (0 if not counted)
contentstring | arrayContent in the requested format: html and markdown are a string, blocks is an array of blocks. Only in GET /articles/{id}; not returned in the list. Empty for an article whose body moved to the platform blog
publicationobject | nullPublication state (below). Only in GET /articles/{id}; null means the article has not been published through the site module yet
created_at, updated_at, published_atstring (ISO 8601) | nullDates, UTC

#Statuses

The statuses the API returns: draft, review (in review), approved, published, rejected, transferred (moved to the platform blog), and external. The status handed_off ("handed off to you") belongs to the future hand-off of an article to your system and is not returned now.

#Publication state (publication)

FieldTypeDescription
target"connector"How it is published: currently always through the module on your site (none arrives with hand-off, which is Planned)
statestringqueued (in the queue or handed to the module), applied, skipped, conflict, failed
published_urlstring | nullAddress of the published page
errorstring | nullReason for the conflict or failure, up to 200 characters

#GET /articles

The tenant's list of articles, without content and without publication. Order: updated_at descending (articles without a date last), then id descending. Only the tenant's own articles are read (sp_seo_articles); articles of the shared platform blog are not included.

ParameterTypeRequiredConstraintsExample
limitintegerno1 to 100, default 2550
cursorstringnoA next_cursor value; lives 24 hours
statusstringnoOne of the statuses above; any other value gives 422approved
updated_afterstringnoISO 8601; only articles with updated_at strictly later2026-10-01T00:00:00Z
include_deletedbooleannoNot supported: the API keeps no deleted articles, true gives 422 validation_failed (not_supported)false
{
  "data": [
    {
      "id": "b9f27c14-0a3d-4e6b-8c51-7d2a9e4f1c03",
      "title": "Как выбрать увлажняющий крем",
      "slug": "kak-vybrat-uvlazhnyayushchij-krem",
      "status": "approved",
      "locale": "ru",
      "word_count": 1850,
      "updated_at": "2026-10-11T10:19:12Z"
    }
  ],
  "next_cursor": null
}
curl "https://formula-cream.pro/api/public/v1/articles?status=approved&limit=50" \
  -H "Authorization: Bearer $SAPPORT_API_KEY"
# SAPPORT_API_KEY=sap_ru_test_EXAMPLE0KEYID_… (an example, not a real key)
const res = await fetch(
  "https://formula-cream.pro/api/public/v1/articles?status=approved&limit=50",
  { headers: { Authorization: `Bearer ${process.env.SAPPORT_API_KEY}` } },
);
const { data, next_cursor } = await res.json();
r = httpx.get(
    "https://formula-cream.pro/api/public/v1/articles",
    params={"status": "approved", "limit": 50},
    headers={"Authorization": f"Bearer {os.environ['SAPPORT_API_KEY']}"},
)
page = r.json()
$ch = curl_init("https://formula-cream.pro/api/public/v1/articles?status=approved&limit=50");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SAPPORT_API_KEY")],
]);
$page = json_decode(curl_exec($ch), true);

#GET /articles/{id}

ParameterTypeRequiredConstraintsExample
idstring (uuid)yesAn article of your tenant
formatstringnohtml, markdown, or blocks; default htmlmarkdown

An article that belongs to someone else or does not exist returns 404 not_found.

Article content is at most 2,000,000 bytes of HTML, the same limits as for a module job. Larger content gives 422 validation_failed on the /content field. An unknown format gives 422 on the /format field.

Formats:

FormatWhat content holds
htmlFinished HTML, stripped of scripts, on* handlers, and javascript: links
markdownThe text as Markdown
blocksAn array of {type, html} blocks with no other fields; the HTML is sanitized (scripts, event handlers, and links or image addresses with a dangerous scheme are removed). An editor (TipTap) document is converted to HTML and returned as a single block {type: "html", html}; the editor's internal JSON is never exposed

Markdown is built from the already sanitized HTML; complex constructs (tables, nested lists) may be simplified. The response is not stored by shared caches (Cache-Control: no-store).

curl "https://formula-cream.pro/api/public/v1/articles/$ARTICLE_ID?format=html" \
  -H "Authorization: Bearer $SAPPORT_API_KEY"
const res = await fetch(
  `https://formula-cream.pro/api/public/v1/articles/${id}?format=html`,
  { headers: { Authorization: `Bearer ${process.env.SAPPORT_API_KEY}` } },
);
const article = await res.json();
r = httpx.get(
    f"https://formula-cream.pro/api/public/v1/articles/{article_id}",
    params={"format": "html"},
    headers={"Authorization": f"Bearer {os.environ['SAPPORT_API_KEY']}"},
)
article = r.json()
$ch = curl_init("https://formula-cream.pro/api/public/v1/articles/$articleId?format=html");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SAPPORT_API_KEY")],
]);
$article = json_decode(curl_exec($ch), true);

#PATCH /articles/{id}

Changes article fields. Send only what you change.

ParameterTypeRequiredConstraintsExample
titlestringno"New title"
slugstringnoA valid page address
contentstringnoHTML up to 2,000,000 bytes; sanitized on write. The request body is at most 256 KB, otherwise 413 payload_too_large; edit large texts in the dashboard
meta_title, meta_descriptionstringno
published_urlstringnoThe page address on your site; confirms publication with target: none"https://example.com/blog/krem"

Invalid values return 422 validation_failed with errors[] (for example, /slug). After you edit a published article, the next send through a module creates a new job version (see publishing through modules).

#POST /articles/{id}/approve

Moves the article to the status approved. No body is needed. Approval is free. From a state in which approval is not allowed (for example, the article is already published), it returns 409 invalid_state.

curl -X POST https://formula-cream.pro/api/public/v1/articles/$ARTICLE_ID/approve \
  -H "Authorization: Bearer $SAPPORT_API_KEY" \
  -H "Idempotency-Key: 5a3c8e21-9f4d-4b76-a1e0-2d7f6c9b8e53"

#POST /articles/{id}/publish

Starts publishing. The target parameter selects the path.

ParameterTypeRequiredConstraintsExample
targetstringyesconnector or none"connector"
site_idstring (uuid)with connectorA bound site of the tenant
publish_modestringwith connectordraft or publish"draft"

#target = connector

The platform creates a module job (see publishing through modules). The site still limits the final mode: if the owner enabled the "drafts only" ceiling, the article ends up as a draft.

ResponseMeaning
200 + a publication objectA new publication version was created (state: queued), or the source did not change and the existing job is returned
404 not_foundThe site or article belongs to someone else or does not exist
409 invalid_stateThe site is not working or not bound, the article is not approved, or there is nothing to publish (an empty article)
422 validation_failedA required parameter is missing; the article exceeds the limits (HTML 2,000,000 bytes, payload 3,000,000 bytes), with errors[].field = /content

Publishing through a module is not an operation in the sense of the operation envelope: the module picks up the job on its next poll. The result (the page address or a conflict code) arrives as the article.published event or is visible via GET /articles/{id} in the publication field.

#target = none

"Mark only": the platform publishes nothing, and the article gets the state handed_off. You publish it yourself, on the article.ready event or from the data of GET /articles/{id}.

An article counts as published only after you confirm the page address: PATCH /articles/{id} with published_url. Until then it stays "handed off".

# 1. Fetch the text
curl "https://formula-cream.pro/api/public/v1/articles/$ARTICLE_ID?format=html" \
  -H "Authorization: Bearer $SAPPORT_API_KEY"

# 2. Declare that you are publishing it yourself
curl -X POST https://formula-cream.pro/api/public/v1/articles/$ARTICLE_ID/publish \
  -H "Authorization: Bearer $SAPPORT_API_KEY" \
  -H "Idempotency-Key: 8d1e6f40-3b2a-4c97-b5f8-0a4e7c9d2b61" \
  -H "Content-Type: application/json" \
  -d '{"target":"none"}'

# 3. Confirm the address
curl -X PATCH https://formula-cream.pro/api/public/v1/articles/$ARTICLE_ID \
  -H "Authorization: Bearer $SAPPORT_API_KEY" \
  -H "Idempotency-Key: 2c9a7d35-6e1b-4f08-8a3d-5b0c1e7f9a24" \
  -H "Content-Type: application/json" \
  -d '{"published_url":"https://example.com/blog/krem"}'
const base = "https://formula-cream.pro/api/public/v1";
const headers = {
  Authorization: `Bearer ${process.env.SAPPORT_API_KEY}`,
  "Content-Type": "application/json",
};

await fetch(`${base}/articles/${id}/publish`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({ target: "none" }),
});

// ...publish on your own site...

await fetch(`${base}/articles/${id}`, {
  method: "PATCH",
  headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({ published_url: "https://example.com/blog/krem" }),
});
base = "https://formula-cream.pro/api/public/v1"
h = {"Authorization": f"Bearer {os.environ['SAPPORT_API_KEY']}"}

httpx.post(f"{base}/articles/{article_id}/publish",
           headers={**h, "Idempotency-Key": str(uuid.uuid4())},
           json={"target": "none"})

# ...publish on your own site...

httpx.patch(f"{base}/articles/{article_id}",
            headers={**h, "Idempotency-Key": str(uuid.uuid4())},
            json={"published_url": "https://example.com/blog/krem"})
$base = "https://formula-cream.pro/api/public/v1";
$call = function (string $method, string $path, array $body) use ($base) {
    $ch = curl_init($base . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer " . getenv("SAPPORT_API_KEY"),
            "Idempotency-Key: " . bin2hex(random_bytes(16)),
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS => json_encode($body),
    ]);
    return json_decode(curl_exec($ch), true);
};

$call("POST", "/articles/$articleId/publish", ["target" => "none"]);
// ...publish on your own site...
$call("PATCH", "/articles/$articleId", ["published_url" => "https://example.com/blog/krem"]);

#Events

EventWhen
article.readyThe article is generated and ready (see generation)
article.publishedPublication is confirmed: the module returned applied with an address, or you passed published_url
article.generation.failedGeneration failed
operation.completedThe article.generation operation finished (generation)

The payload is thin: the type, article_id, and on publication, published_url. For signing and retries, see Webhooks.

#Notes

#Errors

For the general format and the code catalog, see Conventions. For these endpoints:

CodeHTTPWhen
invalid_api_key401The key is not accepted
scope_missing403Missing articles:read / articles:write
not_found404The article or site does not exist or belongs to someone else
invalid_state409The action is not allowed in the current state of the article or site
idempotency_conflict409The Idempotency-Key was already used with a different body
payload_too_large413The request body is larger than 256 KB
validation_failed422Invalid values; the article exceeds the limits
rate_limited429The rate limit was exceeded

#Open questions