#Generating Articles via the API
Status: Planned. There is no public generation endpoint. Today an article is ordered in the Sapport dashboard. This page describes the design; field names are preliminary. No dates are promised.
Generation is a paid asynchronous operation: you create a generation job, receive 202 Accepted with an operation envelope (Asynchronous operations), and the result arrives as an event once the article is ready. The article is written according to your editorial profile (see the overview) and lands in the queue with the status "draft".
Access: scope articles:generate. Paid: yes, charged to the tenant's wallet. PII: no.
#Requirements
| Condition | What happens if it is not met |
|---|---|
| The tenant's editorial profile is filled in | 409 editorial_profile_required |
| Model access is available (a funded wallet or your own model key) | 402 insufficient_funds or 503 upstream_unavailable |
| The tenant or integration spending limit is not reached, not all 5 concurrent paid operations are in use, and the estimate does not exceed the per-operation cost cap (by default the equivalent of 100 RUB) | 402 spend_limit_reached |
| The limits store is available | 503 service_unavailable |
The key carries the articles:generate scope | 403 scope_missing |
For error codes and format, see Conventions.
#How it works
POST /articles/generation-jobscreates the job and reserves the cost on the wallet. The response is202with anoperationenvelope (type: article.generation,resource: {type: generation_job, id}).- The platform queues the job and a worker processes it. Generating one article takes minutes (the platform's internal bulk generation allows 3 to 5 minutes per article; no time limit is promised for the API).
- When the article is ready, an article with the status
draftis created, and thearticle.readyandoperation.completedevents arrive. If generation fails, you getarticle.generation.failedandoperation.completedwith the statusfailed, and the reserve is returned. - You read the article (
GET /articles/{id}), edit it, approve it, and publish it; see the articles API.
There are three ways to get the result: the ?wait=N parameter (up to 20 seconds) on the creating request, polling GET /operations/{id}, or polling the resource GET /articles/generation-jobs/{id}.
#POST /articles/generation-jobs
Headers: Authorization: Bearer <key>, Idempotency-Key (required for paid operations), Content-Type: application/json.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
topic | string | no | The article topic. If neither topic nor topic_id is given, the topic is chosen from the editorial profile's topic sources | "How to choose a moisturizer" |
topic_id | string (uuid) | no | An approved topic from the strategist queue (state approved); mutually exclusive with topic. Otherwise 409 invalid_state | |
keyword | string | no | The main search query | "moisturizer" |
locale | string | no | The article language; defaults to the profile language | "en" |
max_cost | object | no | A cost ceiling: {"amount":"50.00","currency":"RUB"}; not higher than the tenant's per-operation cost cap. If the estimate is higher, the job is not created (402 spend_limit_reached) | |
wait (query) | integer | no | 0 to 20 seconds: wait for the same job to finish | 20 |
The design does not define the parameter set in detail: it is known that generation follows the editorial profile and can rely on a topic or a keyword. The parameters listed are preliminary (see "Open questions").
Response 202 Accepted: an operation envelope:
{
"operation": {
"id": "op_EXAMPLE02",
"type": "article.generation",
"status": "queued",
"created_at": "2026-10-11T10:15:00Z",
"resource": { "type": "generation_job", "id": "e4c1a7b2-5d3f-4a90-b8c6-1f2e3d4c5b6a" }
}
}
Repeating a request with the same Idempotency-Key and the same body returns the same operation and does not charge twice; with a different body, 409 idempotency_conflict. If the result did not arrive within wait, 202 is returned with the current state of the operation.
#GET /articles/generation-jobs/{job_id}
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
job_id (path) | string (uuid) | yes | The resource.id from the operation envelope | e4c1a7b2-… |
Response 200 OK:
{
"job_id": "e4c1a7b2-5d3f-4a90-b8c6-1f2e3d4c5b6a",
"status": "succeeded",
"article_id": "b9f27c14-0a3d-4e6b-8c51-7d2a9e4f1c03",
"estimated_cost": { "amount": "35.00", "currency": "RUB" },
"usage": { "cost": { "amount": "34.20", "currency": "RUB" }, "model": "model-EXAMPLE" },
"error": null,
"created_at": "2026-10-11T10:15:00Z",
"finished_at": "2026-10-11T10:19:12Z"
}
estimated_cost is the estimate before the run; usage is the actual cost and the model (filled in on completion).
Job statuses:
| Status | Meaning |
|---|---|
queued | In the queue |
running | Being generated |
succeeded | Done, article_id is filled in |
failed | Failed; error ({code, message}) describes the reason, the reserve is returned |
canceled | Canceled, the reserve is returned |
The statuses match those of the operation envelope. A job that belongs to someone else or does not exist returns 404 not_found.
#Examples
curl -X POST https://formula-cream.pro/api/public/v1/articles/generation-jobs \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: 0e8a2f61-7c4b-4d19-a3f5-9b1c6d2e8f47" \
-H "Content-Type: application/json" \
-d '{"topic":"Как выбрать увлажняющий крем","locale":"ru"}'
# 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/generation-jobs?wait=20",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAPPORT_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({ topic: "Как выбрать увлажняющий крем", locale: "ru" }),
},
);
if (res.status === 202) {
const { operation } = await res.json();
const jobId = operation.resource.id;
}
import os, uuid, httpx
r = httpx.post(
"https://formula-cream.pro/api/public/v1/articles/generation-jobs",
headers={"Authorization": f"Bearer {os.environ['SAPPORT_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4())},
params={"wait": 20},
json={"topic": "Как выбрать увлажняющий крем", "locale": "ru"},
timeout=30,
)
assert r.status_code == 202
job_id = r.json()["operation"]["resource"]["id"]
$ch = curl_init("https://formula-cream.pro/api/public/v1/articles/generation-jobs");
curl_setopt_array($ch, [
CURLOPT_POST => true,
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(
["topic" => "Как выбрать увлажняющий крем", "locale" => "ru"],
JSON_UNESCAPED_UNICODE
),
]);
$operation = json_decode(curl_exec($ch), true)["operation"];
Polling the status of the operation or the job:
curl https://formula-cream.pro/api/public/v1/operations/$OPERATION_ID \
-H "Authorization: Bearer $SAPPORT_API_KEY"
curl https://formula-cream.pro/api/public/v1/articles/generation-jobs/$JOB_ID \
-H "Authorization: Bearer $SAPPORT_API_KEY"
#Events
| Event | When |
|---|---|
article.ready | The article is generated and in the queue |
article.generation.failed | Generation failed |
operation.completed | The article.generation operation finished (succeeded, failed, canceled); data carries the operation envelope |
Article event payloads are thin: the type, job_id, and article_id. For signing, retries, and verification, see Webhooks.
#Cost
The actual cost is returned in usage ({cost: {amount, currency}, model}) in the operation envelope and in the job; the estimate before the run is estimated_cost. The charge is made to the tenant's wallet; on error the reserve is returned. Test keys do not charge money. For the general rules, see Paid operations.
#Notes
- Generation runs in the worker queue;
?waitwaits at most 20 seconds, so do not count on a finished article within that window. - Approving a strategist topic does not start generation by itself; you order it with this request using
topic_id, or with thegenerate: trueflag in topic approval. - The "deep" mode and scheduled bulk generation are available only on the Sapport site itself and are not part of the public API.
- A topic that is already in your queue may be rejected by the planner as a duplicate; the API behavior in this case is undefined.
#Open questions
- Job parameter set (technical). Topic, keyword, count, mode, length; the names
topic_idandmax_costare preliminary. - Rate and accuracy of
estimated_cost(for the owner). What the generation rate will be and how accurate the estimate must be. - Duplicate topic (technical). What the platform does with a topic that is already in the queue: an error, a job rejection, or a silent substitution.
- Profile error in the dashboard (technical). The dashboard returns
409with the codeEDITORIAL_PROFILE_REQUIREDnot inproblem+jsonformat; the public API returnseditorial_profile_requiredfrom the catalog.