#Articles API
Status: reading is Available; editing, approval, publishing, and generation are Planned.
GET /articlesandGET /articles/{id}work.PATCH,POST …/approve, andPOST …/publishare 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
| Cell | URL |
|---|---|
| RU | https://formula-cream.pro/api/public/v1 |
| EN | https://wfacademy.org/api/public/v1 |
| ID | https://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 path | Scope | What it does |
|---|---|---|
GET /articles | articles:read | List articles (Available) |
GET /articles/{id} | articles:read | An article; content format html, markdown, or blocks (Available) |
PATCH /articles/{id} | articles:write | Edit fields (Planned) |
POST /articles/{id}/approve | articles:write | Approve (Planned) |
POST /articles/{id}/publish | articles:write | Publish through a module or hand off to your system (Planned) |
POST /articles/generation-jobs | articles:generate | Order generation (paid); see the description |
#The article object
| Field | Type | Description |
|---|---|---|
id | string (uuid) | ID |
title | string | Title |
slug | string | null | Page address |
status | string | Article status (see below) |
locale | string | null | Language: ru, en, id |
keyword | string | null | Main search query |
meta_title, meta_description | string | null | SEO title and description |
cover_image_url | string | null | Cover image |
word_count | integer | Word count (0 if not counted) |
content | string | array | Content 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 |
publication | object | null | Publication 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_at | string (ISO 8601) | null | Dates, 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)
| Field | Type | Description |
|---|---|---|
target | "connector" | How it is published: currently always through the module on your site (none arrives with hand-off, which is Planned) |
state | string | queued (in the queue or handed to the module), applied, skipped, conflict, failed |
published_url | string | null | Address of the published page |
error | string | null | Reason 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.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
limit | integer | no | 1 to 100, default 25 | 50 |
cursor | string | no | A next_cursor value; lives 24 hours | |
status | string | no | One of the statuses above; any other value gives 422 | approved |
updated_after | string | no | ISO 8601; only articles with updated_at strictly later | 2026-10-01T00:00:00Z |
include_deleted | boolean | no | Not 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}
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
id | string (uuid) | yes | An article of your tenant | |
format | string | no | html, markdown, or blocks; default html | markdown |
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:
| Format | What content holds |
|---|---|
html | Finished HTML, stripped of scripts, on* handlers, and javascript: links |
markdown | The text as Markdown |
blocks | An 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.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
title | string | no | "New title" | |
slug | string | no | A valid page address | |
content | string | no | HTML 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_description | string | no | ||
published_url | string | no | The 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.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
target | string | yes | connector or none | "connector" |
site_id | string (uuid) | with connector | A bound site of the tenant | |
publish_mode | string | with connector | draft 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.
| Response | Meaning |
|---|---|
200 + a publication object | A new publication version was created (state: queued), or the source did not change and the existing job is returned |
404 not_found | The site or article belongs to someone else or does not exist |
409 invalid_state | The site is not working or not bound, the article is not approved, or there is nothing to publish (an empty article) |
422 validation_failed | A 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
| Event | When |
|---|---|
article.ready | The article is generated and ready (see generation) |
article.published | Publication is confirmed: the module returned applied with an address, or you passed published_url |
article.generation.failed | Generation failed |
operation.completed | The 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
- Editing an article after publication does not change the page on the site until you send it again.
- All
POSTandPATCHrequests acceptIdempotency-Key; repeating with the same body is safe (Idempotency). - Publishing conflicts through a module (
edited_on_site,deleted_on_site, and others) are described in publishing through modules. - Read and write limits are in Conventions; the starting values are 600 / 120 requests per minute.
#Errors
For the general format and the code catalog, see Conventions. For these endpoints:
| Code | HTTP | When |
|---|---|---|
invalid_api_key | 401 | The key is not accepted |
scope_missing | 403 | Missing articles:read / articles:write |
not_found | 404 | The article or site does not exist or belongs to someone else |
invalid_state | 409 | The action is not allowed in the current state of the article or site |
idempotency_conflict | 409 | The Idempotency-Key was already used with a different body |
payload_too_large | 413 | The request body is larger than 256 KB |
validation_failed | 422 | Invalid values; the article exceeds the limits |
rate_limited | 429 | The rate limit was exceeded |
#Open questions
- Name of the hand-off status (technical).
handed_offis not approved and is not returned yet. - Set of
blocks(technical). The editor block format may grow. - Taking down from the site (
unpublish) via the API (technical). The module and dashboard support it; the design does not name it for the API. - Manually editing a published article (technical). How to use the API on an
edited_on_siteconflict. publishresponse bodies (technical). The exact bodies for different outcomes, including "too large" and "empty", will be settled in OpenAPI.- Publishing schedule (technical).
scheduled_atis not implemented.