◆ Sapport for developers API reference Error catalog OpenAPI
Sections

#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

ConditionWhat happens if it is not met
The tenant's editorial profile is filled in409 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 available503 service_unavailable
The key carries the articles:generate scope403 scope_missing

For error codes and format, see Conventions.

#How it works

  1. POST /articles/generation-jobs creates the job and reserves the cost on the wallet. The response is 202 with an operation envelope (type: article.generation, resource: {type: generation_job, id}).
  2. 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).
  3. When the article is ready, an article with the status draft is created, and the article.ready and operation.completed events arrive. If generation fails, you get article.generation.failed and operation.completed with the status failed, and the reserve is returned.
  4. 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.

ParameterTypeRequiredConstraintsExample
topicstringnoThe 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_idstring (uuid)noAn approved topic from the strategist queue (state approved); mutually exclusive with topic. Otherwise 409 invalid_state
keywordstringnoThe main search query"moisturizer"
localestringnoThe article language; defaults to the profile language"en"
max_costobjectnoA 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)integerno0 to 20 seconds: wait for the same job to finish20

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}

ParameterTypeRequiredConstraintsExample
job_id (path)string (uuid)yesThe resource.id from the operation envelopee4c1a7b2-…

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:

StatusMeaning
queuedIn the queue
runningBeing generated
succeededDone, article_id is filled in
failedFailed; error ({code, message}) describes the reason, the reserve is returned
canceledCanceled, 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

EventWhen
article.readyThe article is generated and in the queue
article.generation.failedGeneration failed
operation.completedThe 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

#Open questions