#Strategist topics and actions
Status: Available (reading) / Planned (decisions).
GET /strategist/topics,GET /strategist/topics/{id},GET /strategist/actionsandGET /strategist/actions/{id}work (scopestrategist:read). Approving, rejecting and confirming (POST …) are Planned: calls return404.
The strategist does not publish or spend money without your confirmation: it prepares a proposal, and you make the decision. In the API there are two kinds of such proposals, and they are two different resources.
Access: strategist:read for reading; strategist:write for approving, rejecting, and confirming. Paid: approving a topic is free; generating an article from a topic is a paid operation with the articles:generate scope (Paid operations). 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 |
Errors, pagination, idempotency: Conventions. Keys and scopes: Authentication. In all examples, the key is deliberately fake.
#Two resources
| Resource | Where it comes from | What is decided | Path |
|---|---|---|---|
| Topic (a topic queue item) | The content planner, competitor analysis, and also the strategist after you confirm its action | Approve a topic for an article, or reject it | /strategist/topics |
| Action (a pending chat action) | The strategist in a conversation: "put a topic in the queue" | Confirm: the topic goes into the topic queue. No article is written | /strategist/actions |
A topic's path from idea to article consists of three separate decisions:
the strategist proposes a topic in chat → you confirm the action → the topic is in the queue (proposed)
│
planner / competitors ─────────────────────────────────────┘ (also put topics in the queue)
▼
you approve the topic → the topic is approved
▼
you order generation → a draft article (draft)
Approving a topic does not start generation. Generation is a paid operation and is started by a separate request: either POST /articles/generation-jobs with topic_id (Article generation), or the same approval request with the generate: true flag; in both cases the cost is explicit and arrives in usage.
Only actions from your own conversation sessions are visible (Chat with the strategist); the queue's topics are shared across the tenant.
#Endpoints
Available — the four read routes; the four POST routes are Planned.
| Method and path | Scope | What it does |
|---|---|---|
GET /strategist/topics | strategist:read | The topic queue |
GET /strategist/topics/{id} | strategist:read | One topic |
POST /strategist/topics/{id}/approve | strategist:write | Approve a topic; with generate: true, also order generation (paid) |
POST /strategist/topics/{id}/reject | strategist:write | Reject a topic |
GET /strategist/actions | strategist:read | Pending actions of your sessions |
GET /strategist/actions/{id} | strategist:read | One action |
POST /strategist/actions/{id}/confirm | strategist:write | Confirm an action |
POST /strategist/actions/{id}/cancel | strategist:write | Reject an action |
#Topic object
| Field | Type | Description |
|---|---|---|
id | string (uuid) | The ID |
title | string | null | The proposed article title |
rationale | string | null | The rationale: why the topic is needed now |
trend_score | number | null | An importance score, 0-100; the queue is ordered by it |
source | string | Where the topic is from: planner, competitors, strategist, and other sources |
status | string | The state (the table below) |
cluster_id | string (uuid) | null | The keyword cluster the topic belongs to |
generated_article_id | string (uuid) | null | The article created from the topic |
proposed_at | string (ISO 8601) | When it was proposed |
decided_at | string (ISO 8601) | null | When a decision was made |
expires_at | string (ISO 8601) | null | When an unprocessed topic goes stale |
#Topic states (status)
| State | Meaning |
|---|---|
proposed | Waiting for a decision |
approved | Approved, the article is not yet ordered |
generating | Article generation is in progress |
generated | A draft article is created, generated_article_id is filled in |
rejected | Rejected by you |
duplicate | Recognized as a repeat of an existing topic |
expired | The time ran out without a decision |
Only a topic in the proposed state can be approved or rejected; in any other state, the request gets 409 invalid_state.
#How the queue works
- The queue is ordered by
trend_score, from highest to lowest; topics without a score (trend_score: null) come last. - An unprocessed topic lives for a limited time (
expires_at); after that it is shown asexpired(the state is derived at read time, and thestatus=expiredfilter takes it into account). - A topic that the strategist proposes in chat is not put in the queue again if there is already a topic with the same title there (ignoring case and punctuation): the strategist answers "such a topic is already in the queue".
- The rationale of topics taken from competitor analysis contains other people's domains and titles. In v1, competitor raw data (
competitors.raw) is unavailable to an integration, so such mentions are cut out ofrationalein the same way as in reports.
#GET /strategist/topics
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
status | string | no | One of the topic states | proposed |
limit | integer | no | 1-100, 25 by default | 25 |
cursor | string | no | The next_cursor value; valid for 24 hours | |
include_deleted | boolean | no | not supported yet: true gives 422 not_supported (deleted topics are not stored) | false |
{
"data": [
{
"id": "7a2c9e41-3b58-4d06-9f17-c8e5b1a02d63",
"title": "Как выбрать увлажняющий крем для сухой кожи",
"rationale": "Запрос растёт: 250 показов в сутки, статьи по теме на сайте нет.",
"trend_score": 78.5,
"source": "planner",
"status": "proposed",
"cluster_id": null,
"generated_article_id": null,
"proposed_at": "2026-10-10T07:00:00Z",
"decided_at": null,
"expires_at": "2026-10-24T07:00:00Z"
}
],
"next_cursor": null
}
curl "https://formula-cream.pro/api/public/v1/strategist/topics?status=proposed" \
-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/strategist/topics?status=proposed",
{ headers: { Authorization: `Bearer ${process.env.SAPPORT_API_KEY}` } },
);
const { data } = await res.json();
r = httpx.get(
"https://formula-cream.pro/api/public/v1/strategist/topics",
params={"status": "proposed"},
headers={"Authorization": f"Bearer {os.environ['SAPPORT_API_KEY']}"},
)
topics = r.json()["data"]
$ch = curl_init("https://formula-cream.pro/api/public/v1/strategist/topics?status=proposed");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SAPPORT_API_KEY")],
]);
$topics = json_decode(curl_exec($ch), true)["data"];
#GET /strategist/topics/{id}
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
id (path) | string (uuid) | yes | A topic of your tenant | 7a2c9e41-… |
The 200 response is a topic object. Someone else's or a nonexistent topic returns 404 not_found.
#POST /strategist/topics/{id}/approve
Approves a topic: it moves to approved. No article is written and no money is charged.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
id (path) | string (uuid) | yes | A topic in the proposed state | 7a2c9e41-… |
generate | boolean | no | false by default. true orders article generation right away (paid); requires the articles:generate scope and a completed editorial profile | false |
max_cost | object | no | Only together with generate: true: a cost ceiling {"amount":"50.00","currency":"RUB"}; no higher than the tenant's single-operation cost limit | |
Idempotency-Key header | string | recommended; required with generate: true | Up to 255 characters | 5c1f8d2e-… |
Without generate, the response is 200 OK:
{
"id": "7a2c9e41-3b58-4d06-9f17-c8e5b1a02d63",
"status": "approved",
"decided_at": "2026-10-11T10:30:00Z"
}
With generate: true, the response is 202 Accepted, an operation envelope of type article.generation (Async operations); the topic moves to generating:
{
"operation": {
"id": "op_EXAMPLE02",
"type": "article.generation",
"status": "queued",
"created_at": "2026-10-11T10:30:00Z",
"resource": { "type": "generation_job", "id": "e4c1a7b2-5d3f-4a90-b8c6-1f2e3d4c5b6a" }
}
}
The result is the same as for any generation: ?wait<=20 on this same request, GET /operations/{id}, GET /articles/generation-jobs/{id}, or the operation.completed and article.ready events (Article generation). The cost arrives in operation.usage. If generation could not be started (no profile, insufficient funds, a spending limit), the topic stays in the approved state, and the error is named by a code from the catalog.
A decision is made once: if two requests arrive at the same time, one executes and the other gets 409 invalid_state; paid generation is not started twice.
curl -X POST "https://formula-cream.pro/api/public/v1/strategist/topics/$TOPIC_ID/approve" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: 5c1f8d2e-9a47-4b30-8e61-d3a5c7f20b94" \
-H "Content-Type: application/json" \
-d '{"generate":true,"max_cost":{"amount":"50.00","currency":"RUB"}}'
const res = await fetch(
`https://formula-cream.pro/api/public/v1/strategist/topics/${topicId}/approve?wait=20`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAPPORT_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({ generate: true }),
},
);
if (res.status === 202) {
const { operation } = await res.json();
}
r = httpx.post(
f"https://formula-cream.pro/api/public/v1/strategist/topics/{topic_id}/approve",
params={"wait": 20},
headers={
"Authorization": f"Bearer {os.environ['SAPPORT_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"generate": True},
timeout=30,
)
$ch = curl_init("https://formula-cream.pro/api/public/v1/strategist/topics/" . $topicId . "/approve?wait=20");
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(["generate" => true]),
]);
$res = curl_exec($ch);
#POST /strategist/topics/{id}/reject
Rejects a topic. Free. The decision is remembered (decided_at), and the topic is no longer offered for approval.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
id (path) | string (uuid) | yes | A topic in the proposed state | 7a2c9e41-… |
Idempotency-Key header | string | recommended | Up to 255 characters | 9b2e4c70-… |
No body is needed. The response is 200 OK:
{
"id": "7a2c9e41-3b58-4d06-9f17-c8e5b1a02d63",
"status": "rejected",
"decided_at": "2026-10-11T10:31:00Z"
}
Only a topic in the proposed state can be rejected. For a topic in any other state (including one already rejected or one with a finished article), the response is 409 invalid_state; the topic's state does not change.
curl -X POST "https://formula-cream.pro/api/public/v1/strategist/topics/$TOPIC_ID/reject" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: 9b2e4c70-1d3a-4f58-a6e2-7c0b9d4e1f35"
#Action object
An action is created by the strategist in a conversation; it is executed only by your request. The strategist cannot confirm its own proposal in any way, neither directly nor with a "second message on your behalf".
| Field | Type | Description |
|---|---|---|
id | string (uuid) | The ID |
session_id | string (uuid) | null | The conversation session in which the action was proposed |
type | string | The kind of action. In v1, propose_topic (put a topic in the queue) |
title | string | What is proposed: for propose_topic, the topic title |
status | string | The state (the table below) |
result | object | null | After execution: {topic_id}, the topic created in the queue |
error | object | null | On failed: {code, message} |
created_at | string (ISO 8601) | When it was proposed |
decided_at | string (ISO 8601) | null | When a decision was made |
expires_at | string (ISO 8601) | null | When it expires: 7 days after the proposal |
#Action states (status)
| State | Meaning |
|---|---|
proposed | Waiting for a decision |
confirmed | Confirmed, being executed |
executed | Executed, result is filled in |
failed | Execution failed, the reason is in error |
cancelled | Rejected by you |
expired | The time (7 days) ran out without a decision |
Only an action in the proposed state can be confirmed or rejected. Confirming again, or confirming an expired or already rejected action, gives 409 invalid_state: the actual state is named in detail. Permissions are checked at the moment of confirmation, not at the moment of the proposal: permissions may have changed in 7 days.
#GET /strategist/actions
Actions of your integration's sessions, newest first. Actions of staff in the dashboard and of other integrations are not visible. Until the strategist chat is released for integrations, the list is empty.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
status | string | no | One of the action states | proposed |
session_id | string (uuid) | no | Only actions of this session | |
limit | integer | no | 1-100, 25 by default | 25 |
cursor | string | no | The next_cursor value; valid for 24 hours |
{
"data": [
{
"id": "d41e7a30-8b25-4c69-a0f3-52c9e1b7d806",
"session_id": "c2e84a17-9d03-4f5b-a6c8-3b71e0d92f45",
"type": "propose_topic",
"title": "Как выбрать увлажняющий крем для сухой кожи",
"status": "proposed",
"result": null,
"error": null,
"created_at": "2026-10-11T10:02:11Z",
"decided_at": null,
"expires_at": "2026-10-18T10:02:11Z"
}
],
"next_cursor": null
}
curl "https://formula-cream.pro/api/public/v1/strategist/actions?status=proposed" \
-H "Authorization: Bearer $SAPPORT_API_KEY"
#GET /strategist/actions/{id}
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
id (path) | string (uuid) | yes | An action of your integration | d41e7a30-… |
The 200 response is an action object. An action of another integration, of another tenant, or a nonexistent one returns 404 not_found.
#POST /strategist/actions/{id}/confirm
Confirms an action and executes it: for propose_topic, a topic in the proposed state appears in the queue, and result.topic_id points to it. Free; no article is written, and the topic must be approved separately.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
id (path) | string (uuid) | yes | An action in the proposed state | d41e7a30-… |
Idempotency-Key header | string | recommended | Up to 255 characters | 3f6a9c12-… |
No body is needed. The response is 200 OK, an action object:
{
"id": "d41e7a30-8b25-4c69-a0f3-52c9e1b7d806",
"session_id": "c2e84a17-9d03-4f5b-a6c8-3b71e0d92f45",
"type": "propose_topic",
"title": "Как выбрать увлажняющий крем для сухой кожи",
"status": "executed",
"result": { "topic_id": "7a2c9e41-3b58-4d06-9f17-c8e5b1a02d63" },
"error": null,
"created_at": "2026-10-11T10:02:11Z",
"decided_at": "2026-10-11T10:40:02Z",
"expires_at": "2026-10-18T10:02:11Z"
}
curl -X POST "https://formula-cream.pro/api/public/v1/strategist/actions/$ACTION_ID/confirm" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: 3f6a9c12-7e50-4b84-9d21-a8c4e0b6f593"
const res = await fetch(
`https://formula-cream.pro/api/public/v1/strategist/actions/${actionId}/confirm`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAPPORT_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
},
},
);
const action = await res.json();
r = httpx.post(
f"https://formula-cream.pro/api/public/v1/strategist/actions/{action_id}/confirm",
headers={
"Authorization": f"Bearer {os.environ['SAPPORT_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
)
action = r.json()
$ch = curl_init("https://formula-cream.pro/api/public/v1/strategist/actions/" . $actionId . "/confirm");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("SAPPORT_API_KEY"),
"Idempotency-Key: " . bin2hex(random_bytes(16)),
],
]);
$action = json_decode(curl_exec($ch), true);
#POST /strategist/actions/{id}/cancel
Rejects an action: it moves to cancelled, and the topic does not enter the queue. Free. The parameters and constraints are the same as for confirm; the 200 OK response is an action object with the cancelled status.
curl -X POST "https://formula-cream.pro/api/public/v1/strategist/actions/$ACTION_ID/cancel" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Idempotency-Key: 6d0b3e85-2a71-4c96-b4f8-1e9a5c7d3028"
#Events
| Event | When |
|---|---|
operation.completed | Generation ordered by approving a topic with generate: true finished |
article.ready | A draft article was created from the topic |
article.generation.failed | Generation from the topic failed |
There are no separate "new topic" and "action confirmed" events. Signature, retries, verification: Webhooks.
#Errors
The general format and the code catalog: Conventions. For these endpoints:
| Code | HTTP | When |
|---|---|---|
invalid_api_key | 401 | The key was not accepted |
scope_missing | 403 | No strategist:read / strategist:write, or, with generate: true, no articles:generate |
not_found | 404 | There is no such topic or action, or it belongs to someone else |
invalid_state | 409 | The topic is not in proposed (approve, reject); the action is not in proposed (confirm, cancel), including an expired one |
editorial_profile_required | 409 | generate: true, but the editorial profile is not completed (Articles) |
validation_failed | 422 | Generation cannot be started: the topic has no keyword; an invalid max_cost |
insufficient_funds | 402 | Not enough funds for generation |
spend_limit_reached | 402 | The spending, concurrency, or operation cost limit was hit |
upstream_unavailable | 503 | No model key, or the model is unavailable |
rate_limited | 429 | The rate limit was exceeded |
#Open questions
- Topic state name (technical). In the internal database, a topic waits for a decision in the
pendingstate; the public nameproposedwas introduced for consistency with actions and with the rule "reject only fromproposed". The mapping is fixed at release. - Refusing to reject outside
proposed(technical). The rule "only fromproposed, otherwise409 invalid_state" is adopted for the public API; the dashboard today rejects a topic without checking its state and must be fixed in the same way. - The
topic_idparameter inPOST /articles/generation-jobs(technical). Ordering generation from an approved topic is described on the Article generation page; the parameter name is preliminary. - Reason for a refusal (technical). The machine-readable reason a topic returns to the queue after an unsuccessful generation start arrives as an error code from the catalog; the full set of reasons (no model key, no keyword) will be settled in OpenAPI.
- Topic series (technical). In the dashboard, the strategist can also propose a series of topics; in v1 only the
propose_topictool is available. Whether a "series" action kind is needed publicly is undecided. - Editing a topic (for the owner). Changing the title or rationale before approval is not provided for in the design. Whether customers need it is for the owner to decide.