# News Factory API The News Factory public REST API: find news in your niche, turn it into stories with AI, check them for quality and SEO, and publish them to your site. Everything the app does with stories, content sources, RSS feeds and workflows, scoped to one organization and one Topic Workspace. **Authentication.** Send `Authorization: Bearer ` with every request. An organization admin creates keys in the app under Organization settings, API keys (Pro, Business and Enterprise plans), choosing the scopes each integration needs and, optionally, one Topic Workspace and an expiry. Keys start with `nfk_` and are shown once. **Start with `GET /me`.** It returns the organization, the key's scopes, the Topic Workspaces the key can act on and anything that would block a request. **Errors** are always `{ "error": { "code", "message", "details", "requestId", "docs" } }`; `docs` links to what the code means and what to do. **Limits.** 100 requests per minute per organization (429 `RATE_LIMIT_EXCEEDED` with `Retry-After`). AI endpoints spend the organization's AI credits (429 `LLM_QUOTA_EXCEEDED` when they run out). **Locale codes** are always full codes such as `en-US`, `pt-BR` or `es-CO`, never bare `en`. ## Quickstart The News Factory API lets software, and AI agents, do what the News Factory app does: find news in a niche, turn articles into stories with AI, check them for quality and SEO, translate them, and publish them. 1. **Get a key.** An organization admin opens the app, goes to Organization settings, API keys, and creates a key. Pick the access it needs (read only, read and write, or full access including AI), optionally one Topic Workspace and an expiry. The key starts with `nfk_` and is shown once: store it as a secret. 2. **Check it.** Call `GET /v1/me`. It answers who the key is, its scopes, the Topic Workspaces it can act on and anything that would block a request. ```bash curl https://client-api.news-factory.app/v1/me \ -H "Authorization: Bearer $NEWS_FACTORY_API_KEY" ``` 3. **List stories.** ```bash curl "https://client-api.news-factory.app/v1/stories?status=published&language=en-US&order=publishedAt%20DESC&limit=10" \ -H "Authorization: Bearer $NEWS_FACTORY_API_KEY" ``` Every response is JSON. Lists answer `{ "data": [...], "meta": { "page", "limit", "hasMore" } }`; single records answer `{ "data": {...} }`; writes answer `{ "success": true, "data": {...}, "message" }`; errors answer `{ "error": { "code", "message", "details", "requestId", "docs" } }`. Machine-readable versions of this reference: [OpenAPI 3.1](https://client-api.news-factory.app/openapi.json), [llms.txt](https://client-api.news-factory.app/llms.txt) and [the whole reference as Markdown](https://client-api.news-factory.app/llms-full.txt). The OpenAPI `operationId`s (`listStories`, `createContentSource`, ...) make good tool names for an agent. ## Authentication and scopes Send the key on every request: ```http Authorization: Bearer nfk_... ``` Keys belong to the organization, not to a person: they keep working when their creator leaves, until an admin revokes them or they expire. Work done with a key is attributed to the admin who created it. An organization can hold up to 25 active keys, so give every integration its own key with only the scopes it needs, and revoke it on its own when it is no longer used. ### Scopes A key carries one or more scopes. An endpoint lists the scopes it needs; a key must have all of them, otherwise the answer is `403 INSUFFICIENT_SCOPE` with `details.missing`. | Scope | Grants | | --- | --- | | `stories:read` | Read stories, their media, quality-check and SEO audit history | | `stories:write` | Create, update and delete stories; upload, attach and detach media; set thumbnails | | `content-sources:read` | Read content sources | | `content-sources:write` | Create, update, delete and scrape content sources | | `feeds:read` | Read followed feeds, the feed catalogue, feed articles and the scheduler status | | `feeds:write` | Follow and unfollow feeds, fetch a feed now | | `workflows:read` | Read workflows, workflow runs and node runs | | `workflows:run` | Start, pause, resume and stop workflow runs | | `ai:generate` | Use AI tools that spend AI credits | AI endpoints need `ai:generate` together with the scope of what they read or change: suggesting titles needs `ai:generate` and `stories:read`; translating a story, which creates one, needs `ai:generate` and `stories:write`. The app offers three presets: **Read only** (every `:read` scope), **Read and write** (adds every `:write` scope and `workflows:run`, no AI) and **Full access** (everything). ### Keeping keys safe - Keys are shown once. News Factory stores only a hash, so a lost key cannot be recovered: create a new one and revoke the old one. - Never put a key in a browser, a mobile app or a public repository. Call the API from a server. - Revoking or expiring a key takes effect everywhere within one minute. - Keys start with `nfk_` so secret scanners can recognise them. ### Legacy keys Keys created before scoped keys (they start with `ak_`) keep working with full access, but they are deprecated. `GET /v1/me` reports them with `deprecated: true` and the problem `LEGACY_API_KEY`. Replace them with scoped keys in the app. ### Plans The API is included in the Pro, Business and Enterprise plans (and their trials). On other plans every endpoint except `GET /v1/me` answers `403 PLAN_REQUIRED`. ## Topic Workspaces Everything in News Factory belongs to a Topic Workspace: a niche with its own feeds, stories, settings and keywords. Every request acts on exactly one. - **A key bound to a Topic Workspace** always acts on it. Leave out `X-Topic-Key`; sending a different one is refused with `403 INVALID_TOPIC`. - **A key for all Topic Workspaces** picks one with the `X-Topic-Key` header. Without the header, the organization's first Topic Workspace is used. An unknown key answers `400 INVALID_TOPIC` with `details.validTopics`. `GET /v1/me` lists the Topic Workspaces a key can use (`data.topic.available`, each `{ key, name }`) and which one the request resolved to. ```bash curl https://client-api.news-factory.app/v1/stories \ -H "Authorization: Bearer $NEWS_FACTORY_API_KEY" \ -H "X-Topic-Key: health" ``` Records never move between Topic Workspaces through the API: the request's Topic Workspace decides where a story or content source is created. ## Errors Every error has the same shape and a matching HTTP status: ```json { "error": { "code": "INSUFFICIENT_SCOPE", "message": "This API key lacks the scope stories:write needed for this endpoint. Edit the key's access in the app or use another key.", "details": { "required": ["stories:write"], "missing": ["stories:write"], "granted": ["stories:read"] }, "requestId": "6f1c2a9e-3b7d-4c41-9f0e-2d8a5b7c9e10", "docs": "https://client-api.news-factory.app/docs#error-insufficient-scope" } } ``` `message` is written for people and may change; branch on `code`. `requestId` is also in the `X-Request-Id` response header: quote it to support. You may send your own `X-Request-Id` (8 to 64 letters, digits, `.`, `_` or `-`) to correlate logs. **`UNAUTHORIZED` (401).** No key, a malformed key, or a revoked or expired key. Do not retry with the same key. Check the `Authorization: Bearer nfk_...` header; if the key was revoked, ask an admin for a new one. Many failed attempts from one address pause that address for a few minutes (429). **`FORBIDDEN` (403).** The request is not allowed for this organization, for example a record of another organization. Do not retry. **`INSUFFICIENT_SCOPE` (403).** The key lacks a scope the endpoint needs. `details.missing` names them. An admin can add scopes to the key in the app, or use a key that has them. **`PLAN_REQUIRED` (403).** The organization's plan does not include the API. It needs Pro, Business or Enterprise. **`INVALID_TOPIC` (400 or 403).** `X-Topic-Key` names a Topic Workspace that does not exist (400, `details.validTopics` lists the valid keys), or the key is bound to another Topic Workspace (403), or the key's Topic Workspace was deleted (403, create a new key). **`NO_TOPIC_WORKSPACE` (409).** The organization has no Topic Workspace yet. Create one in the app. **`NOT_FOUND` (404).** The record does not exist in this Topic Workspace, or the path is not an endpoint (see this reference). **`METHOD_NOT_ALLOWED` (405).** The path exists but not with this HTTP method. The `Allow` header lists the methods it supports. **`VALIDATION_ERROR` (400, 413 or 422).** A parameter or body field is missing or wrong; `message` says which. 413 means the body is too large (10 MB for image uploads, 1 MB otherwise). 422 from an apply-fix endpoint means the text changed since the check; retry with `mode: "rewrite"`. Fix the request before retrying. **`CONFLICT` (409).** The request clashes with the current state: a content source with this link already exists (`details.existingContentSourceId`), a workflow run is already active (`details.runId`), a run already finished. Use the existing record or wait. **`PLAN_LIMIT_REACHED` (403).** A plan limit stops the action (feeds, languages, storage, ...). `details.limitKey`, `details.limit` and `details.current` say which. An admin can upgrade or buy an add-on. **`RATE_LIMIT_EXCEEDED` (429).** More than 100 requests in a minute for the organization, too many failed authentications from one address, or a job queue that is busy. Wait the number of seconds in `Retry-After`, then retry. **`DAILY_QUOTA_EXCEEDED` (429).** The plan's daily request quota is used up. `details.resetsAt` is the next midnight UTC. **`LLM_QUOTA_EXCEEDED` (429).** The organization has no AI credits left for today. `details.resetsAt` says when the daily allowance comes back. Non-AI endpoints keep working. **`UPSTREAM_ERROR` (502).** A News Factory service failed. Retry with exponential backoff (for example 2, 4, 8 seconds); if it keeps failing, contact support with the `requestId`. **`GATEWAY_MISCONFIGURED` (503).** API keys cannot be verified right now. Retry in a minute. **`INTERNAL_ERROR` (500).** Something unexpected failed. Retry once; then contact support with the `requestId`. ### Retrying safely Retry `429`, `502`, `503` and `500` with backoff. Never blindly retry a `POST` that creates something (a story, a content source, a translation, a run) after a timeout: list the records first, or you may create a duplicate. Do not retry other `4xx` answers without changing the request. ## Pagination and filtering List endpoints take `limit` (default 20, at most 100) and `page` (zero based) and answer `meta: { page, limit, hasMore }`. `hasMore` is true when the page was full, so request the next page until it is false. `page * limit` can be at most 10000; narrow the query with filters to reach older records. Most lists sort with `order= ASC|DESC` on the fields each endpoint documents. ### Stories `GET /v1/stories` and `GET /v1/stories/by-language/{language}` take flat filters (`status`, `language`, `tag`, `search`, `dateFrom`, `dateTo`, `include`) or one advanced `filter` JSON in LoopBack's shape: ```json { "where": { "status": "published", "publishedAt": { "gte": "2026-08-01T00:00:00Z" } }, "include": ["images"], "order": ["publishedAt DESC"], "limit": 50 } ``` Rules for `filter` (a filter that breaks one answers `400 VALIDATION_ERROR` naming it): - `where` fields: id, title, slug, summary, content, language, status, tags, thumbnail, thumbnailImageId, mainImageId, mainImageUrl, translationStatus, publishedAt, firstReported, createdAt, updatedAt. Other fields are ignored. - Operators: eq, neq, gt, gte, lt, lte, between, inq, nin, like, nlike, ilike, nilike, exists. `regexp` and `near` are not supported. - `like` patterns are regular expressions of at most 100 characters, without groups, repetition counts or back references. - `and` / `or` / `nor` nest at most 3 levels with at most 20 conditions each; `inq` / `nin` take at most 100 values; strings at most 500 characters; `offset` / `skip` at most 10000. Story dates: `publishedAt` is when the story was first published (`null` until then; kept if it is unpublished later), `firstReported` when its earliest source was published, `createdAt` when News Factory wrote it, `updatedAt` its last change. `dateFrom` / `dateTo` filter on `createdAt`; filter on publication with `where.publishedAt`. A page of the latest published stories is `status=published&order=publishedAt DESC`. `include` adds relations to each story: `images`, `authors`, `categories`, `qualityChecks`, `seoAudits`, `translations`, `contentSources`. Relations you do not request are never returned. ### Locale codes Languages are always full locale codes: `en-US`, `en-GB`, `pt-BR`, `es-CO`, `de-DE`. Bare codes such as `en` are not used anywhere in the API. ## Limits | Limit | Value | When exceeded | | --- | --- | --- | | Requests per organization | 100 per minute | `429 RATE_LIMIT_EXCEEDED`, `Retry-After` header | | Failed authentications per address | 60 per 5 minutes | `429 RATE_LIMIT_EXCEEDED` for that address | | Daily requests | set by the plan (none today) | `429 DAILY_QUOTA_EXCEEDED`, resets at midnight UTC | | AI credits | the organization's daily allowance plus add-ons | `429 LLM_QUOTA_EXCEEDED` on AI endpoints | | Request body | 10 MB for image uploads, 1 MB otherwise | `413 VALIDATION_ERROR` | | Active API keys | 25 per organization | creating more is refused in the app | | Workflow runs | one active run per organization | `409 CONFLICT` with `details.runId` | Responses carry `RateLimit` and `RateLimit-Policy` headers (IETF draft 7) so a client can slow down before it is limited. When a daily quota applies, `X-Quota-Limit`, `X-Quota-Remaining` and `X-Quota-Reset` are set too. AI endpoints (marked with the `ai:generate` scope) can take up to a few minutes. Use a client timeout of at least 5 minutes for them and do not retry them on a timeout without checking whether the result already exists. ## Recipes for agents Short, tested sequences for common goals. Each step names the `operationId` from the OpenAPI document. ### Before anything: know your key `getMe` (`GET /v1/me`): check `data.problems` is empty, note `data.credential.scopes` and `data.topic.available`. If a step below needs a scope the key lacks, stop and tell the user which scope to add. ### Write a story from a web article Scopes: `content-sources:write`, `content-sources:read`, `stories:write`, `ai:generate`. 1. `createContentSource` (`POST /v1/content-sources`) with `{ "title": "", "link": "
", "sourceType": "website" }`. A `409 CONFLICT` means it exists: use `details.existingContentSourceId`. 2. `scrapeContentSource` (`POST /v1/content-sources/{id}/scrape`) to fetch and parse the page. 3. `repurposeContentSource` (`POST /v1/content-sources/{id}/repurpose`) with `{ "languageCode": "en-US" }`. It answers `data.storyId` of the new draft. 4. `getStoryById` (`GET /v1/stories/{id}`) to read the result. ### Check and improve a story before publishing Scopes: `stories:read`, `stories:write`, `ai:generate`. 1. `qualityCheck` (`POST /v1/stories/{storyId}/content/quality-check`) and `seoAudit` (`POST /v1/stories/{storyId}/content/seo-audit`). 2. For each finding or fix worth applying: `applyQualityFix` or `applySeoFix` with the finding or `fix` object from the report. On `422` retry with `"mode": "rewrite"`. 3. `updateStory` (`PATCH /v1/stories/{id}`) with `{ "status": "published" }` when the story is ready. News Factory records the moment in `publishedAt`. ### Show the latest published stories Scope: `stories:read`. `listStories` (`GET /v1/stories?status=published&order=publishedAt DESC`) answers the newest publications first; show each story's `publishedAt` as its date and rewrite the links between stories (`/{slug}`) to your own route, as [Rendering stories on your site](#guide-rendering-stories) explains. For one language use `listStoriesByLanguage` with the same params; for a date window use `filter` with `where.publishedAt` (`gte` / `lte`). ### Suggest what to read next Scope: `stories:read`. `getStoryById` (`GET /v1/stories/{id}`) or `getStoryBySlug` answers `data.relatedStories`: up to 5 published stories in the same language, closest first, each with `slug`, `title`, `summary` and a `score` from 0 to 1. Use them for a Read next block, for internal links when you write or edit a story, or to spot that a topic was already covered. They are found within about a minute of publication: right after `updateStory` publishes a story, read it again a minute later for its list. ### Publish in another language Scopes: `stories:write`, `ai:generate`. `translateStory` (`POST /v1/stories/{storyId}/content/translate`) with `{ "languageCode": "de-DE" }` creates the translated story. Read every language version with `include=translations`, or list one language with `listStoriesByLanguage`. ### Pick relevant news from followed feeds Scopes: `feeds:read` (and the scopes of the recipe above to write from it). 1. `listFeeds` (`GET /v1/feeds`) for the feeds the Topic Workspace follows; `searchFeedCatalog` and `subscribeFeed` to follow more (`feeds:write`). 2. `listFeedArticles` (`GET /v1/feeds/articles?minConfidence=70`) for articles already judged relevant to the Topic Workspace, highest confidence first with `order=matchConfidence DESC`. 3. Write a story from an article's `url` with the first recipe. ### Run a workflow and wait for it Scopes: `workflows:read`, `workflows:run`. 1. `listWorkflows` (`GET /v1/workflows`) and pick one (`isActive=true`). 2. `runWorkflow` (`POST /v1/workflows/{id}/run`) answers `202` with `data.runId`. A `409` means a run is already active: use `details.runId`. 3. Poll `getWorkflowRun` (`GET /v1/workflow-runs/{runId}`) every 10 to 30 seconds until `status` is `completed`, `failed` or `cancelled`. `listNodeRuns` shows per-step progress. `pauseWorkflowRun`, `resumeWorkflowRun` and `cancelWorkflowRun` (Stop) control it. ### Good manners - Read before you write; never create duplicates by retrying a `POST` after a timeout. - Keep to 100 requests a minute and honour `Retry-After`. - AI endpoints spend the organization's credits: run them only when the user asked for the result. ## Images and provenance Every image the client API returns (in `images[]` on stories, and from the `/stories/{storyId}/media` endpoints) is a **public Image object**. Storage internals are stripped and three provenance fields are added so a consumer can decide which picture to show without knowing how News Factory stores files. ### Public Image object ```json { "id": "6a99370b892980259c5de946", "url": "https://news-factory.s3.us-east-1.amazonaws.com/.../generated_openai_1788425993651_0.png", "width": 1024, "height": 576, "format": "PNG", "contentType": "image/png", "fileSize": 1583022, "imageType": "main", "origin": "generated", "aiGenerated": true, "generation": { "provider": "openai", "model": "gpt-image-1", "prompt": "Topic tags: Amazon, AI, Cloud Computing ... Image Requirements: Editorial illustration ..." }, "sourceType": "generated", "parentId": null, "title": "AI Generated Image - openai", "description": "...", "altText": null, "caption": null, "author": "openai AI", "source": "openai", "license": null, "creditLine": null, "originalUrl": null, "tags": ["ai-generated", "openai", "ai"], "createdAt": "2026-09-03T08:59:55.424Z", "updatedAt": "2026-09-03T08:59:55.424Z" } ``` | Field | Type | Meaning | | --- | --- | --- | | `url` | string \| null | Public URL of the file. `s3Url` is kept as a deprecated alias with the same value. | | `imageType` | string | Role of the image on the story: `main`, `thumbnail`, `gallery`, `content`, `other`. | | `origin` | `generated` \| `stock` \| `source` \| `upload` | Normalized provenance (see below). | | `aiGenerated` | boolean | **Authoritative flag**: `true` only when News Factory produced the image with an AI image model, including crops and thumbnails derived from such an image. | | `generation` | object \| null | `{ provider, model, prompt }` when `aiGenerated` is true, otherwise `null`. Any of the three can be `null` for older images. | | `sourceType` | string | Raw storage source (`generated`, `stock`, `story`, `content-source`, `scraped`, `user-upload`, `url`). Kept for backwards compatibility; **do not use it to detect AI images**, resized thumbnails were historically stored as `generated` too. | | `parentId` | string \| null | Id of the image this one was derived from (crop / thumbnail). | | `originalUrl` | string \| null | Where the bytes were fetched from, for images copied from an article or imported by URL. | | `author`, `source`, `license`, `creditLine` | string \| null | Attribution. For stock images `source` is the provider (`unsplash`, `pexels`, ...); for article images it is the article URL; for AI images the provider id. | #### `origin` values | `origin` | Comes from | Typical use | | --- | --- | --- | | `generated` | An AI image model run by News Factory (`aiGenerated: true`), or a legacy row that only recorded `sourceType: "generated"` and whose parent is not in the same response | Safe to publish under your own brand when `aiGenerated` is true; for legacy rows check `aiGenerated` first. | | `stock` | A licensed stock provider (Unsplash, Pexels, ...) | Check `license` / `creditLine` for attribution requirements. | | `source` | The original article / content source the story was written from | Usually **not** yours to republish; treat as a preview only. | | `upload` | Uploaded by a user or imported by URL through the API | Whatever rights you have to the file you uploaded. | Derived images (a `parentId` pointing at another image in the same response, e.g. a resized thumbnail) take the origin of their parent when their own record does not carry the AI flag, so a thumbnail of the publisher's photo reports `origin: "source"` even though it is stored as `sourceType: "generated"`. ### Getting images with stories Images are an opt-in include on every story read: ``` GET /v1/stories?include=images&language=en-US&limit=20 GET /v1/stories/by-language/es-CO?include=images GET /v1/stories/{id}?include=images GET /v1/stories/slug/{slug}?include=images GET /v1/stories/{id}/media ``` The story itself also carries quick-access fields: | Field | Meaning | | --- | --- | | `thumbnail` | URL of the current thumbnail. Match it against `images[].url` to learn its provenance. | | `thumbnailImageId` | Id of the `images[]` entry backing `thumbnail` (set for stories processed after 2026-09-05). | | `mainImageId`, `mainImageUrl` | The current main image (set when a main image is generated, picked from stock or reused from the source through the Image Gen workflow node, for stories processed after 2026-09-05). | ### Choosing which image to show Prefer the flag, never `sourceType`: ```js function pickHeroImage(story) { const images = story.images ?? []; const generated = images.filter((img) => img.aiGenerated && img.url); return ( generated.find((img) => img.imageType === "main") ?? generated.find((img) => img.imageType === "thumbnail") ?? generated[0] ?? null // fall back to your own placeholder ); } ``` To show *any* image but avoid republishing the publisher's photo: ```js images.filter((img) => img.origin !== "source" && img.url) ``` ### Media endpoints `GET /stories/{storyId}/media` returns `{ success, data: PublicImage[], meta: { count } }`. `POST /stories/{storyId}/media`, `POST .../media/from-url` and `POST .../media/{imageId}` return the created/attached image in the same public shape. `POST .../media/generate` returns the raw generation result (`provider`, `model`, `images[]` with `imageId` and `url`); re-read the story with `include=images` to get the public objects with provenance. ## Connect an AI agent (MCP) The API is also a Model Context Protocol server at `https://client-api.news-factory.app/mcp` (Streamable HTTP, stateless). Every endpoint is a tool named after its `operationId` (`getMe`, `listStories`, `translateStory`, ...) with its parameters and body as the input, and a key only sees the tools its scopes allow. A tool call runs the same REST request with the same key, so scopes, the Topic Workspace, limits and errors are exactly the API's. Claude Code: ```bash claude mcp add --transport http news-factory https://client-api.news-factory.app/mcp \ --header "Authorization: Bearer $NEWS_FACTORY_API_KEY" ``` Any MCP client that can send a header works the same way: the URL above and `Authorization: Bearer `. Give the agent a key with only the scopes it needs, for example a Read only key for research, and add `ai:generate` only when it should spend AI credits. Tool results are the API's JSON answers, prefixed with the HTTP status (`HTTP 200`). A failed call is a tool error carrying the API's error body. ## Rendering stories on your site A story's `content` is HTML, ready to place in your page. Three things need handling before you publish it: links to other stories, dates, and stories still being written. Every story also tells you which of your other stories to show next. ### Links to other stories News Factory links one story to another (for example the SEO step's internal links) with the linked story's slug at the site root: ```html AI-generated ``` News Factory stores the slug, never a URL: your site decides where a story lives. Rewrite these links to your own story route before rendering, or they lead to a 404 on any site that does not serve stories at `/{slug}`. - **Recognise them.** A story link's `href` is exactly `/` followed by a slug (lowercase letters, digits and hyphens), optionally followed by `?query` or `#fragment`. Leave every other link alone: other sites, your own pages, anchors. - **Rewrite them** to your route, for example `/{slug}` to `/news/{slug}`, `/es/news/{slug}` or `https://example.com/blog/{slug}`. Keep the query or fragment. - **Translations.** In a translated story, a link points at the linked story's version in the same language when that version existed at translation time, otherwise at the original. `GET /v1/stories/slug/{slug}` finds a story in any language; its `language` and `translations` tell you which of your pages to link. - **Check the target.** A linked story can be unpublished, deleted, hollow (no body yet) or outside what your site shows. Link only to stories you have a page for (your own list of slugs, or `GET /v1/stories/slug/{slug}` not answering 404), and otherwise keep the text without the link. ```js // Point story links at your route; drop the ones you have no page for. const STORY_LINK = /]*?\bhref\s*=\s*)(["'])\/([a-z0-9][a-z0-9-]*)\/?([?#][^"']*)?\2([^>]*)>([\s\S]*?)<\/a>/gi; function rewriteStoryLinks(html, { route = (slug) => `/news/${slug}`, hasPage = () => true } = {}) { return html.replace(STORY_LINK, (link, before, quote, slug, suffix = "", after, text) => hasPage(slug) ? `${text}` : text, ); } // rewriteStoryLinks(story.content, { hasPage: (slug) => publishedSlugs.has(slug) }) ``` If a body can link to one of your own top-level pages (for example `/pricing`), skip those paths before rewriting: they are pages, not stories. ### Related stories (Read next) Every story read one at a time (`GET /v1/stories/{id}`, `GET /v1/stories/slug/{slug}`) and every story of `GET /v1/stories/by-language/{language}` carries `relatedStories`: up to 5 published stories of the same Topic Workspace and the same language that are closest to it in meaning, closest first. No `include` is needed. The plain list (`GET /v1/stories`) leaves them out to stay light. ```json "relatedStories": [ { "id": "68b9b7c2f1a2b3c4d5e6f740", "title": "Hospitals Cut Waiting Times with AI Triage", "slug": "hospitals-cut-waiting-times-with-ai-triage", "summary": "Emergency departments using AI triage report shorter waits and fewer missed cases.", "language": "en-US", "publishedAt": "2026-08-12T09:10:00.000Z", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/generated_openai_1788125993651_0.png", "thumbnail": null, "score": 0.8412 } ] ``` - **When they appear.** News Factory finds them when a story is published, within about a minute, and again when a published story gets a new title or summary. Until then the list is empty, and it stays empty when no published story is close enough (`score` below 0.7). A story published later joins the lists of the older stories it is close to, so lists keep up without anyone republishing. - **Always current.** Only stories that are published right now are listed, with their current title and slug: one you unpublish or delete drops out at once and the next closest takes its place. - **Per language.** A story's related stories are in its own language and never include another language version of the same story. A translation gets its own list, from the stories published in that language. - **Render them** like any link to a story: point `slug` at your own route (`/news/{slug}`), and skip the ones you have no page for, as above. ```js // Read next: up to 3 related stories your site has a page for. const readNext = story.relatedStories.filter((r) => publishedSlugs.has(r.slug)).slice(0, 3); // {r.title} ``` A static site built from `GET /v1/stories/by-language/{language}` gets every story's list in the same response, without a call per story; rebuild it now and then so new publications show up in older stories' Read next blocks. ### Dates - `publishedAt`: when the story was first published (`null` until then; kept if it is unpublished later). Show it as the publication date and order lists with `status=published&order=publishedAt DESC`. - `updatedAt`: the last change, for "Updated on". - `firstReported`: when the earliest source article was published, not your story. - `createdAt`: when News Factory wrote the story. ### Stories still being written A workflow creates a story with its title first and writes the body a moment later, and a story can stay without one. Skip stories whose `content` has no real text (for example fewer than 80 words once the HTML is stripped) so a list never links to an empty page. ## Account Who the credential is and what it may do. Start here. ### Who Am I `GET /v1/me` · operationId `getMe` · scopes: any valid key Describe the credential: the organization, the plan, the key's scopes, the Topic Workspaces it can act on, the rate limit and where the documentation lives. It answers even when the plan or the Topic Workspace would block every other endpoint, and lists the problem in `problems`, so call it first. **Responses** - `200` The credential and what it may do | Field | Type | Description | | --- | --- | --- | | `data` | object | The description | | `data.credential.type` | string | `api-key`, `legacy-api-key` (a key from before scoped keys, full access, replace it) or `session` (the in-app playground) | | `data.credential.keyId` | string \| null | Id of the API key | | `data.credential.name` | string \| null | Name given to the key in the app | | `data.credential.scopes` | object[] | Granted scopes: `{ scope, description }` | | `data.credential.expiresAt` | string \| null | When the key stops working (ISO 8601), or null | | `data.credential.deprecated` | boolean | True for a legacy key | | `data.organization.id` | string | Organization id | | `data.organization.plan` | string | Plan slug: starter, pro, business, enterprise or free_org | | `data.organization.apiIncluded` | boolean | Whether the plan includes the API (Pro, Business, Enterprise) | | `data.topic.current` | string \| null | Topic Workspace this request resolved to (honours X-Topic-Key) | | `data.topic.boundTo` | string \| null | Topic Workspace the key is bound to, or null when it may use any | | `data.topic.available` | object[] | Topic Workspaces the key can act on: `{ key, name }` | | `data.topic.howToChoose` | string | How to pick a Topic Workspace with this key | | `data.limits.requestsPerMinute` | number | Per-organization rate limit | | `data.limits.dailyRequests` | number \| null | Daily request quota, or null when the plan sets none | | `data.problems` | object[] | Anything that blocks or will block requests: `{ code, message }` (PLAN_REQUIRED, INVALID_TOPIC, NO_TOPIC_WORKSPACE, LEGACY_API_KEY) | | `data.docs.reference` | string | HTML reference | | `data.docs.openapi` | string | OpenAPI 3.1 specification | | `data.docs.llms` | string | llms.txt index for AI agents | | `data.docs.llmsFull` | string | The whole reference as one Markdown file | ```json { "data": { "credential": { "type": "api-key", "keyId": "68f0c1a2b3c4d5e6f7a8b9c0", "name": "Website sync", "scopes": [ { "scope": "stories:read", "description": "Read stories, their media, quality-check and SEO audit history" }, { "scope": "feeds:read", "description": "Read followed feeds, the feed catalogue, feed articles and the scheduler status" } ], "expiresAt": null, "deprecated": false }, "organization": { "id": "org_2abc123", "plan": "pro", "apiIncluded": true }, "topic": { "current": "tech", "boundTo": "tech", "available": [ { "key": "tech", "name": "Technology" } ], "howToChoose": "This key always acts on its Topic Workspace; X-Topic-Key is not needed." }, "limits": { "requestsPerMinute": 100, "dailyRequests": null }, "problems": [], "docs": { "reference": "https://client-api.news-factory.app/docs", "openapi": "https://client-api.news-factory.app/openapi.json", "llms": "https://client-api.news-factory.app/llms.txt", "llmsFull": "https://client-api.news-factory.app/llms-full.txt" } } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `429` Rate limited: more than 100 requests in one minute per organization. Wait for the Retry-After seconds. **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/me" \ -H "Authorization: Bearer nfk_xxx..." ``` ## Stories Read, create and publish the stories News Factory writes, with their images, translations, quality checks and SEO audits ### List Stories `GET /v1/stories` · operationId `listStories` · scopes: `stories:read` Page through the Topic Workspace's original stories (translations are reached through `include=translations` or `GET /stories/by-language/{language}`). Filter with the flat params or the advanced `filter` JSON. The list is lightweight: `content`, `bulletPoints`, `sentiment` and `metadata` are only returned by the detail endpoints. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `language` | string (`en-US`, `en-GB`, `de-DE`, `fr-FR`, `es-ES`, `es-MX`, `it-IT`, `pt-BR`, `pt-PT`, `nl-NL`, `pl-PL`, `sv-SE`, `da-DK`, `nb-NO`, `fi-FI`, `cs-CZ`, `ro-RO`, `hr-HR`, `tr-TR`, `ja-JP`) | no | Only stories in this language (full locale code such as en-US) | | `tag` | string | no | Only stories carrying this tag (accent and case insensitive) | | `search` | string | no | Full text search on title, summary and tags. Combines with the paging, order and include params. | | `status` | string (`draft`, `pending`, `approved`, `rejected`, `scheduled`, `published`, `archived`, `deleted`) | no | Only stories in this status | | `dateFrom` | string | no | Only stories created on or after this instant (for publication dates use `filter` on `publishedAt`) | | `dateTo` | string | no | Only stories created on or before this instant | | `include` | string | no | Comma separated relations to embed: images, authors, categories, qualityChecks, seoAudits, translations, contentSources. Relations you do not ask for are never returned. `images` returns public Image objects with `origin`, `aiGenerated` and `generation`. | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | | `order` | string (`createdAt DESC`, `createdAt ASC`, `publishedAt DESC`, `updatedAt DESC`, `firstReported DESC`, `title ASC`, `title DESC`) | no | Sort order as `field ASC\|DESC` (default createdAt DESC). Allowed fields: createdAt, publishedAt, updatedAt, firstReported, title. | | `filter` | string | no | Advanced LoopBack-style filter as a JSON string. Supports `where` (with `and` / `or` / `nor`, nested at most 3 levels with 20 conditions each, and the operators eq, neq, gt, gte, lt, lte, between, inq, nin, like, nlike, ilike, nilike, exists), `include`, `order`, `limit`, `offset` or `skip` (at most 10000), and `fields`. Allowed where fields: id, title, slug, summary, content, language, status, tags, thumbnail, thumbnailImageId, mainImageId, mainImageUrl, translationStatus, publishedAt, firstReported, createdAt, updatedAt. `like` patterns are regular expressions of at most 100 characters without groups or repetition counts; `regexp` and `near` are not supported; strings are at most 500 characters and `inq` / `nin` take at most 100 values. A filter that breaks a rule answers 400 VALIDATION_ERROR naming the rule. When present it replaces status, language, dateFrom, dateTo, include, limit, page and order (tag and search still apply). | **Responses** - `200` A page of stories | Field | Type | Description | | --- | --- | --- | | `data` | Story[] | Stories, newest first by default | | `data[].id` | string | Unique story identifier | | `data[].title` | string | Story title | | `data[].summary` | string | Short summary | | `data[].slug` | string | URL-friendly identifier, unique per Topic Workspace and language | | `data[].status` | string | draft, pending, approved, rejected, scheduled, published, archived or deleted | | `data[].language` | string | Full locale code of the story text, e.g. en-US, de-DE, pt-BR | | `data[].tags` | string[] | Tags | | `data[].topicKey` | string | Topic Workspace the story belongs to | | `data[].parentId` | string \| null | Id of the original story when this one is a translation, else null | | `data[].translationStatus` | string \| null | pending, in-progress, completed or needs-update (translations only) | | `data[].thumbnail` | string | URL of the current thumbnail. Match it against `images[].url` to learn its provenance | | `data[].thumbnailImageId` | string | Id of the `images[]` entry backing `thumbnail` | | `data[].mainImageId` | string | Id of the current main image, when one was generated, picked from stock or reused from the source | | `data[].mainImageUrl` | string | URL of the current main image | | `data[].publishedAt` | string \| null | ISO 8601 timestamp of the first publication: set when `status` first becomes `published`, kept if the story is unpublished later, null until then | | `data[].firstReported` | string | ISO 8601 timestamp of the earliest source publication | | `data[].createdAt` | string | ISO 8601 creation timestamp | | `data[].updatedAt` | string | ISO 8601 timestamp of the last update | | `data[].images` | Image[] | All images attached to the story (only with `include=images`). Each item is a public Image object; prefer the ones with `aiGenerated: true` over the publisher's photo. | | `data[].images[].id` | string | Unique image identifier | | `data[].images[].url` | string | Public URL of the image file | | `data[].images[].s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data[].images[].width` | number | Width in pixels | | `data[].images[].height` | number | Height in pixels | | `data[].images[].format` | string | File format (PNG, JPEG, WEBP) | | `data[].images[].contentType` | string | MIME type of the image | | `data[].images[].fileSize` | number \| null | File size in bytes | | `data[].images[].imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data[].images[].origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data[].images[].aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data[].images[].generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data[].images[].sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data[].images[].parentId` | string \| null | Id of the image this one was cropped or resized from | | `data[].images[].originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data[].images[].title` | string \| null | Image title | | `data[].images[].description` | string \| null | Image description | | `data[].images[].altText` | string \| null | Alt text | | `data[].images[].caption` | string \| null | Caption | | `data[].images[].author` | string \| null | Author or photographer | | `data[].images[].source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data[].images[].license` | string \| null | License (stock images) | | `data[].images[].creditLine` | string \| null | Credit line to display (stock images) | | `data[].images[].tags` | string[] | Tags associated with the image | | `data[].images[].createdAt` | string | ISO 8601 creation timestamp | | `data[].images[].updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `data[].authors` | Author[] | Assigned authors (only with `include=authors`): `{ id, name, status, avatarUrl, bio, topicKey }` | | `data[].categories` | Category[] | Assigned categories (only with `include=categories`): `{ id, name, slug, description, parentId }` | | `data[].qualityChecks` | QualityCheck[] | Quality check history, newest first (only with `include=qualityChecks`). Same objects as `GET /stories/{id}/content/quality-checks`. | | `data[].seoAudits` | SeoAudit[] | SEO audit history, newest first (only with `include=seoAudits`). Same objects as `GET /stories/{id}/content/seo-audits`. | | `data[].translations` | Translation[] | Every language version of this story including itself (only with `include=translations`): `{ code, storyId, default, slug, title }`. `default` marks the original. | | `data[].contentSources` | ContentSourceRef[] | Sources the story was written from (only with `include=contentSources`): `{ id, title, link, sourceType, sourceMeta }`. Add `fullContentSources=true` on the detail endpoints for the complete records. | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f700", "title": "AI Revolution in Healthcare", "summary": "How artificial intelligence is transforming medical diagnostics.", "slug": "ai-revolution-in-healthcare", "status": "published", "language": "en-US", "tags": [ "ai", "healthcare", "technology" ], "topicKey": "tech", "parentId": null, "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/thumb_400x225.jpg", "thumbnailImageId": "68b9b7c2f1a2b3c4d5e6f703", "mainImageId": "68b9b7c2f1a2b3c4d5e6f702", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "publishedAt": "2026-08-15T11:45:00.000Z", "firstReported": "2026-08-15T08:00:00.000Z", "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T12:00:00.000Z", "images": [ { "id": "68b9b7c2f1a2b3c4d5e6f701", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "originalUrl": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "width": 1024, "height": 683, "format": "JPEG", "contentType": "image/jpeg", "fileSize": 214532, "imageType": "main", "origin": "source", "aiGenerated": false, "generation": null, "sourceType": "story", "parentId": null, "title": "AI Revolution in Healthcare", "description": null, "altText": null, "caption": null, "author": null, "source": "https://techcrunch.com/2026/07/30/ai-healthcare/", "license": null, "creditLine": null, "tags": [], "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" }, { "id": "68b9b7c2f1a2b3c4d5e6f702", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "originalUrl": null, "width": 1024, "height": 576, "format": "PNG", "contentType": "image/png", "fileSize": 1583022, "imageType": "main", "origin": "generated", "aiGenerated": true, "generation": { "provider": "openai", "model": "gpt-image-1", "prompt": "Editorial illustration of AI-assisted medical diagnostics in a modern hospital" }, "sourceType": "generated", "parentId": null, "title": "AI Generated Image - openai", "description": null, "altText": null, "caption": null, "author": "openai AI", "source": "openai", "license": null, "creditLine": null, "tags": [ "ai-generated", "openai", "healthcare" ], "createdAt": "2026-08-15T11:02:00.000Z", "updatedAt": "2026-08-15T11:02:00.000Z" } ], "translations": [ { "code": "en-US", "storyId": "68b9b7c2f1a2b3c4d5e6f700", "default": true, "slug": "ai-revolution-in-healthcare", "title": "AI Revolution in Healthcare" }, { "code": "de-DE", "storyId": "68b9b7c2f1a2b3c4d5e6f710", "default": false, "slug": "ki-revolution-im-gesundheitswesen", "title": "KI-Revolution im Gesundheitswesen" }, { "code": "pt-BR", "storyId": "68b9b7c2f1a2b3c4d5e6f711", "default": false, "slug": "revolucao-da-ia-na-saude", "title": "Revolução da IA na saúde" } ] } ], "meta": { "page": 0, "limit": 20, "hasMore": true } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `429` Rate limited: more than 100 requests in one minute per organization. Wait for the Retry-After seconds. **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/stories?status=published&language=en-US&include=images%2Ctranslations&limit=20" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Create Story `POST /v1/stories` · operationId `createStory` · scopes: `stories:write` Create a story in the Topic Workspace. Only `title` is required; everything else defaults (draft status, the Topic Workspace's default language). Fields outside the documented list are ignored. **Request body** (JSON): Story data | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | yes | Story title | | `summary` | string | no | Short summary | | `content` | string | no | Full HTML body. Link another story of the Topic Workspace with `` | | `bulletPoints` | string[] | no | Key points | | `language` | string | no | Full locale code (default: the Topic Workspace's default language) | | `status` | string | no | draft, pending, approved, rejected, scheduled, published, archived or deleted (default draft) | | `publishedAt` | string (ISO 8601) \| null | no | When the story was published. Set automatically the first time `status` becomes `published`; send a date-time to backdate it, or null to clear it | | `tags` | string[] | no | Tags | | `slug` | string | no | URL slug (generated from the title when omitted) | | `thumbnail` | string | no | Thumbnail URL | | `metadata` | object | no | SEO metadata: title, description, keywords, canonical, ogImage and custom keys | | `topicKey` | string | no | Topic Workspace to create the story in (default: the key's Topic Workspace or the organization's first Topic Workspace) | ```json { "title": "AI Revolution in Healthcare", "summary": "How AI is transforming medical diagnostics", "content": "

Full story content here...

", "language": "en-US", "status": "draft", "tags": [ "ai", "healthcare" ] } ``` **Responses** - `201` Story created | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data` | Story | The created story | | `data.id` | string | Unique story identifier | | `data.title` | string | Story title | | `data.summary` | string | Short summary | | `data.content` | string | Full HTML body of the story. Links to other stories are written as `
` (a slug, never a URL): point them at your own story route before rendering, as the guide Rendering stories on your site explains | | `data.bulletPoints` | string[] | Key points | | `data.slug` | string | URL-friendly identifier, unique per Topic Workspace and language | | `data.status` | string | draft, pending, approved, rejected, scheduled, published, archived or deleted | | `data.language` | string | Full locale code of the story text, e.g. en-US, de-DE, pt-BR | | `data.tags` | string[] | Tags | | `data.topicKey` | string | Topic Workspace the story belongs to | | `data.parentId` | string \| null | Id of the original story when this one is a translation, else null | | `data.translationStatus` | string \| null | pending, in-progress, completed or needs-update (translations only) | | `data.sentiment` | number | Sentiment score from -1 (negative) to 1 (positive) | | `data.thumbnail` | string | URL of the current thumbnail | | `data.thumbnailImageId` | string | Id of the `images[]` entry backing `thumbnail` | | `data.mainImageId` | string | Id of the current main image | | `data.mainImageUrl` | string | URL of the current main image | | `data.metadata` | object | SEO metadata and custom fields | | `data.metadata.title` | string | Meta title | | `data.metadata.description` | string | Meta description | | `data.metadata.keywords` | string | Meta keywords, comma separated | | `data.metadata.canonical` | string | Canonical URL | | `data.metadata.ogImage` | string | Open Graph image URL | | `data.publishedAt` | string \| null | ISO 8601 timestamp of the first publication: set when `status` first becomes `published`, kept if the story is unpublished later, null until then | | `data.firstReported` | string | ISO 8601 timestamp of the earliest source publication | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string | ISO 8601 timestamp of the last update | | `data.images` | Image[] | All images attached to the story (only with `include=images`). Each item is a public Image object; prefer the ones with `aiGenerated: true` over the publisher's photo. | | `data.images[].id` | string | Unique image identifier | | `data.images[].url` | string | Public URL of the image file | | `data.images[].s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data.images[].width` | number | Width in pixels | | `data.images[].height` | number | Height in pixels | | `data.images[].format` | string | File format (PNG, JPEG, WEBP) | | `data.images[].contentType` | string | MIME type of the image | | `data.images[].fileSize` | number \| null | File size in bytes | | `data.images[].imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data.images[].origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data.images[].aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data.images[].generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data.images[].sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data.images[].parentId` | string \| null | Id of the image this one was cropped or resized from | | `data.images[].originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data.images[].title` | string \| null | Image title | | `data.images[].description` | string \| null | Image description | | `data.images[].altText` | string \| null | Alt text | | `data.images[].caption` | string \| null | Caption | | `data.images[].author` | string \| null | Author or photographer | | `data.images[].source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data.images[].license` | string \| null | License (stock images) | | `data.images[].creditLine` | string \| null | Credit line to display (stock images) | | `data.images[].tags` | string[] | Tags associated with the image | | `data.images[].createdAt` | string | ISO 8601 creation timestamp | | `data.images[].updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `data.authors` | Author[] | Assigned authors (only with `include=authors`): `{ id, name, status, avatarUrl, bio, topicKey }` | | `data.categories` | Category[] | Assigned categories (only with `include=categories`): `{ id, name, slug, description, parentId }` | | `data.qualityChecks` | QualityCheck[] | Quality check history, newest first (only with `include=qualityChecks`). Same objects as `GET /stories/{id}/content/quality-checks`. | | `data.seoAudits` | SeoAudit[] | SEO audit history, newest first (only with `include=seoAudits`). Same objects as `GET /stories/{id}/content/seo-audits`. | | `data.translations` | Translation[] | Every language version of this story including itself (only with `include=translations`): `{ code, storyId, default, slug, title }`. `default` marks the original. | | `data.contentSources` | ContentSourceRef[] | Sources the story was written from (only with `include=contentSources`): `{ id, title, link, sourceType, sourceMeta }`. Add `fullContentSources=true` on the detail endpoints for the complete records. | | `message` | string | Success message | ```json { "success": true, "data": { "id": "68b9b7c2f1a2b3c4d5e6f700", "title": "AI Revolution in Healthcare", "summary": "How artificial intelligence is transforming medical diagnostics.", "slug": "ai-revolution-in-healthcare", "status": "draft", "language": "en-US", "tags": [ "ai", "healthcare", "technology" ], "topicKey": "tech", "parentId": null, "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/thumb_400x225.jpg", "thumbnailImageId": "68b9b7c2f1a2b3c4d5e6f703", "mainImageId": "68b9b7c2f1a2b3c4d5e6f702", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "publishedAt": null, "firstReported": "2026-08-15T08:00:00.000Z", "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T12:00:00.000Z", "content": "

Overview

A comprehensive look at how AI is being deployed across diagnostics, triage and treatment planning.

", "bulletPoints": [ "AI diagnostics accuracy reaches 95%", "Cost reduction of 40% in pilot hospitals" ], "sentiment": 0.8, "metadata": { "title": "AI Revolution in Healthcare: What Changes in 2026", "description": "AI is transforming diagnostics, triage and treatment planning. Here is what hospitals report.", "keywords": "ai, healthcare, diagnostics" } }, "message": "Story created successfully" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"title":"AI Revolution in Healthcare","summary":"How AI is transforming medical diagnostics","content":"

Full story content here...

","language":"en-US","status":"draft","tags":["ai","healthcare"]}' ``` ### List Stories by Language
`GET /v1/stories/by-language/{language}` · operationId `listStoriesByLanguage` · scopes: `stories:read` Page through every story written in one language, originals and translations alike, with a stable order. Ideal for building a per-language site: pair it with `include=translations` to render language switchers. Every story carries its `relatedStories` in that language, so a static site can render Read next blocks without a call per story. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `language` | string (`en-US`, `en-GB`, `de-DE`, `fr-FR`, `es-ES`, `es-MX`, `it-IT`, `pt-BR`, `pt-PT`, `nl-NL`, `pl-PL`, `sv-SE`, `da-DK`, `nb-NO`, `fi-FI`, `cs-CZ`, `ro-RO`, `hr-HR`, `tr-TR`, `ja-JP`) | yes | Full locale code, e.g. en-US, de-DE, pt-BR | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string (`draft`, `pending`, `approved`, `rejected`, `scheduled`, `published`, `archived`, `deleted`) | no | Only stories in this status | | `dateFrom` | string | no | Only stories created on or after this instant (for publication dates use `filter` on `publishedAt`) | | `dateTo` | string | no | Only stories created on or before this instant | | `include` | string | no | Comma separated relations to embed: images, authors, categories, qualityChecks, seoAudits, translations, contentSources. Relations you do not ask for are never returned. `images` returns public Image objects with `origin`, `aiGenerated` and `generation`. | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | | `order` | string (`createdAt DESC`, `createdAt ASC`, `publishedAt DESC`, `updatedAt DESC`, `firstReported DESC`, `title ASC`, `title DESC`) | no | Sort order as `field ASC\|DESC` (default createdAt DESC). Allowed fields: createdAt, publishedAt, updatedAt, firstReported, title. | | `filter` | string | no | Advanced LoopBack-style filter as a JSON string. Supports `where` (with `and` / `or` / `nor`, nested at most 3 levels with 20 conditions each, and the operators eq, neq, gt, gte, lt, lte, between, inq, nin, like, nlike, ilike, nilike, exists), `include`, `order`, `limit`, `offset` or `skip` (at most 10000), and `fields`. Allowed where fields: id, title, slug, summary, content, language, status, tags, thumbnail, thumbnailImageId, mainImageId, mainImageUrl, translationStatus, publishedAt, firstReported, createdAt, updatedAt. `like` patterns are regular expressions of at most 100 characters without groups or repetition counts; `regexp` and `near` are not supported; strings are at most 500 characters and `inq` / `nin` take at most 100 values. A filter that breaks a rule answers 400 VALIDATION_ERROR naming the rule. When present it replaces status, language, dateFrom, dateTo, include, limit, page and order (tag and search still apply). | **Responses** - `200` A page of stories in that language | Field | Type | Description | | --- | --- | --- | | `data` | Story[] | Stories in the requested language | | `data[].id` | string | Unique story identifier | | `data[].title` | string | Story title | | `data[].summary` | string | Short summary | | `data[].slug` | string | URL-friendly identifier, unique per Topic Workspace and language | | `data[].status` | string | draft, pending, approved, rejected, scheduled, published, archived or deleted | | `data[].language` | string | Full locale code of the story text, e.g. en-US, de-DE, pt-BR | | `data[].tags` | string[] | Tags | | `data[].topicKey` | string | Topic Workspace the story belongs to | | `data[].parentId` | string \| null | Id of the original story when this one is a translation, else null | | `data[].translationStatus` | string \| null | pending, in-progress, completed or needs-update (translations only) | | `data[].thumbnail` | string | URL of the current thumbnail. Match it against `images[].url` to learn its provenance | | `data[].thumbnailImageId` | string | Id of the `images[]` entry backing `thumbnail` | | `data[].mainImageId` | string | Id of the current main image, when one was generated, picked from stock or reused from the source | | `data[].mainImageUrl` | string | URL of the current main image | | `data[].publishedAt` | string \| null | ISO 8601 timestamp of the first publication: set when `status` first becomes `published`, kept if the story is unpublished later, null until then | | `data[].firstReported` | string | ISO 8601 timestamp of the earliest source publication | | `data[].createdAt` | string | ISO 8601 creation timestamp | | `data[].updatedAt` | string | ISO 8601 timestamp of the last update | | `data[].images` | Image[] | All images attached to the story (only with `include=images`). Each item is a public Image object; prefer the ones with `aiGenerated: true` over the publisher's photo. | | `data[].images[].id` | string | Unique image identifier | | `data[].images[].url` | string | Public URL of the image file | | `data[].images[].s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data[].images[].width` | number | Width in pixels | | `data[].images[].height` | number | Height in pixels | | `data[].images[].format` | string | File format (PNG, JPEG, WEBP) | | `data[].images[].contentType` | string | MIME type of the image | | `data[].images[].fileSize` | number \| null | File size in bytes | | `data[].images[].imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data[].images[].origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data[].images[].aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data[].images[].generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data[].images[].sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data[].images[].parentId` | string \| null | Id of the image this one was cropped or resized from | | `data[].images[].originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data[].images[].title` | string \| null | Image title | | `data[].images[].description` | string \| null | Image description | | `data[].images[].altText` | string \| null | Alt text | | `data[].images[].caption` | string \| null | Caption | | `data[].images[].author` | string \| null | Author or photographer | | `data[].images[].source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data[].images[].license` | string \| null | License (stock images) | | `data[].images[].creditLine` | string \| null | Credit line to display (stock images) | | `data[].images[].tags` | string[] | Tags associated with the image | | `data[].images[].createdAt` | string | ISO 8601 creation timestamp | | `data[].images[].updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `data[].authors` | Author[] | Assigned authors (only with `include=authors`): `{ id, name, status, avatarUrl, bio, topicKey }` | | `data[].categories` | Category[] | Assigned categories (only with `include=categories`): `{ id, name, slug, description, parentId }` | | `data[].qualityChecks` | QualityCheck[] | Quality check history, newest first (only with `include=qualityChecks`). Same objects as `GET /stories/{id}/content/quality-checks`. | | `data[].seoAudits` | SeoAudit[] | SEO audit history, newest first (only with `include=seoAudits`). Same objects as `GET /stories/{id}/content/seo-audits`. | | `data[].translations` | Translation[] | Every language version of this story including itself (only with `include=translations`): `{ code, storyId, default, slug, title }`. `default` marks the original. | | `data[].contentSources` | ContentSourceRef[] | Sources the story was written from (only with `include=contentSources`): `{ id, title, link, sourceType, sourceMeta }`. Add `fullContentSources=true` on the detail endpoints for the complete records. | | `data[].relatedStories` | RelatedStory[] | Up to 5 published stories of the same Topic Workspace and language that are closest to this one in meaning, closest first: ready for a Read next block. Always returned, no `include` needed. Found when the story is published (within about a minute) and again when a published story gets a new title or summary; empty until then and when no story is close enough. Only stories published right now are listed, with their current title and slug, and never another language version of this story. | | `data[].relatedStories[].id` | string | Id of the related story | | `data[].relatedStories[].title` | string | Its current title | | `data[].relatedStories[].slug` | string | Its slug. Point it at your own story route, like the links inside `content` | | `data[].relatedStories[].summary` | string | Its short summary | | `data[].relatedStories[].language` | string | Full locale code, always the language of this story | | `data[].relatedStories[].publishedAt` | string \| null | ISO 8601 timestamp of its first publication | | `data[].relatedStories[].mainImageUrl` | string \| null | URL of its main image, when it has one | | `data[].relatedStories[].thumbnail` | string \| null | URL of its thumbnail, often the source publisher's photo | | `data[].relatedStories[].score` | number | Similarity to this story from 0 to 1, higher is closer. Listed stories score 0.7 or more | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | | `meta.language` | string | The language requested | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f700", "title": "AI Revolution in Healthcare", "summary": "How artificial intelligence is transforming medical diagnostics.", "slug": "ai-revolution-in-healthcare", "status": "published", "language": "en-US", "tags": [ "ai", "healthcare", "technology" ], "topicKey": "tech", "parentId": null, "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/thumb_400x225.jpg", "thumbnailImageId": "68b9b7c2f1a2b3c4d5e6f703", "mainImageId": "68b9b7c2f1a2b3c4d5e6f702", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "publishedAt": "2026-08-15T11:45:00.000Z", "firstReported": "2026-08-15T08:00:00.000Z", "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T12:00:00.000Z", "translations": [ { "code": "en-US", "storyId": "68b9b7c2f1a2b3c4d5e6f700", "default": true, "slug": "ai-revolution-in-healthcare", "title": "AI Revolution in Healthcare" }, { "code": "de-DE", "storyId": "68b9b7c2f1a2b3c4d5e6f710", "default": false, "slug": "ki-revolution-im-gesundheitswesen", "title": "KI-Revolution im Gesundheitswesen" }, { "code": "pt-BR", "storyId": "68b9b7c2f1a2b3c4d5e6f711", "default": false, "slug": "revolucao-da-ia-na-saude", "title": "Revolução da IA na saúde" } ], "relatedStories": [ { "id": "68b9b7c2f1a2b3c4d5e6f740", "title": "Hospitals Cut Waiting Times with AI Triage", "slug": "hospitals-cut-waiting-times-with-ai-triage", "summary": "Emergency departments using AI triage report shorter waits and fewer missed cases.", "language": "en-US", "publishedAt": "2026-08-12T09:10:00.000Z", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/generated_openai_1788125993651_0.png", "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/thumb_400x225.jpg", "score": 0.8412 }, { "id": "68b9b7c2f1a2b3c4d5e6f741", "title": "Regulators Draft Rules for Diagnostic AI", "slug": "regulators-draft-rules-for-diagnostic-ai", "summary": "A draft framework would require clinical trials before diagnostic models reach patients.", "language": "en-US", "publishedAt": "2026-08-09T15:30:00.000Z", "mainImageUrl": null, "thumbnail": null, "score": 0.7761 } ] } ], "meta": { "page": 0, "limit": 20, "hasMore": false, "language": "en-US" } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/stories/by-language/en-US?include=translations&limit=20" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Translation Map `GET /v1/stories/translation-map` · operationId `translationMap` · scopes: `stories:read` A compact map from each story to all of its language versions, without loading the stories themselves. Use it to generate hreflang tags or language switchers. With `lang`, entries are anchored on the stories in that language so the map lines up with `GET /stories/by-language/{language}`. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `lang` | string (`en-US`, `en-GB`, `de-DE`, `fr-FR`, `es-ES`, `es-MX`, `it-IT`, `pt-BR`, `pt-PT`, `nl-NL`, `pl-PL`, `sv-SE`, `da-DK`, `nb-NO`, `fi-FI`, `cs-CZ`, `ro-RO`, `hr-HR`, `tr-TR`, `ja-JP`) | no | Anchor the map on stories in this language (full locale code) | | `limit` | number | no | Maximum entries (default 500, max 1000) | **Responses** - `200` The map | Field | Type | Description | | --- | --- | --- | | `data` | object[] | One entry per anchor story | | `data[].id` | string | Story id | | `data[].translations` | object[] | Every language version of the family, the original first: `{ id, code, slug }` | | `meta.count` | number | Entries returned | | `meta.lang` | string \| null | Language filter applied | | `meta.limit` | number | Limit applied | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f700", "translations": [ { "id": "68b9b7c2f1a2b3c4d5e6f700", "code": "en-US", "slug": "ai-revolution-in-healthcare" }, { "id": "68b9b7c2f1a2b3c4d5e6f710", "code": "de-DE", "slug": "ki-revolution-im-gesundheitswesen" }, { "id": "68b9b7c2f1a2b3c4d5e6f711", "code": "pt-BR", "slug": "revolucao-da-ia-na-saude" } ] } ], "meta": { "count": 1, "lang": "en-US", "limit": 500 } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/stories/translation-map?lang=en-US&limit=200" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Get Story `GET /v1/stories/{id}` · operationId `getStoryById` · scopes: `stories:read` One story with its full HTML content, key points, sentiment, SEO metadata and `relatedStories`: up to 5 published stories in the same language that are closest to it, for a Read next block. Add relations with `include`. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The story id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `include` | string | no | Comma separated relations to embed: images, authors, categories, qualityChecks, seoAudits, translations, contentSources. Relations you do not ask for are never returned. `images` returns public Image objects with `origin`, `aiGenerated` and `generation`. | | `fullContentSources` | boolean | no | With `include=contentSources`, return the complete source records instead of the short references | **Responses** - `200` The story | Field | Type | Description | | --- | --- | --- | | `data` | Story | The story with its full content | | `data.id` | string | Unique story identifier | | `data.title` | string | Story title | | `data.summary` | string | Short summary | | `data.content` | string | Full HTML body of the story. Links to other stories are written as `` (a slug, never a URL): point them at your own story route before rendering, as the guide Rendering stories on your site explains | | `data.bulletPoints` | string[] | Key points | | `data.slug` | string | URL-friendly identifier, unique per Topic Workspace and language | | `data.status` | string | draft, pending, approved, rejected, scheduled, published, archived or deleted | | `data.language` | string | Full locale code of the story text, e.g. en-US, de-DE, pt-BR | | `data.tags` | string[] | Tags | | `data.topicKey` | string | Topic Workspace the story belongs to | | `data.parentId` | string \| null | Id of the original story when this one is a translation, else null | | `data.translationStatus` | string \| null | pending, in-progress, completed or needs-update (translations only) | | `data.sentiment` | number | Sentiment score from -1 (negative) to 1 (positive) | | `data.thumbnail` | string | URL of the current thumbnail | | `data.thumbnailImageId` | string | Id of the `images[]` entry backing `thumbnail` | | `data.mainImageId` | string | Id of the current main image | | `data.mainImageUrl` | string | URL of the current main image | | `data.metadata` | object | SEO metadata and custom fields | | `data.metadata.title` | string | Meta title | | `data.metadata.description` | string | Meta description | | `data.metadata.keywords` | string | Meta keywords, comma separated | | `data.metadata.canonical` | string | Canonical URL | | `data.metadata.ogImage` | string | Open Graph image URL | | `data.publishedAt` | string \| null | ISO 8601 timestamp of the first publication: set when `status` first becomes `published`, kept if the story is unpublished later, null until then | | `data.firstReported` | string | ISO 8601 timestamp of the earliest source publication | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string | ISO 8601 timestamp of the last update | | `data.images` | Image[] | All images attached to the story (only with `include=images`). Each item is a public Image object; prefer the ones with `aiGenerated: true` over the publisher's photo. | | `data.images[].id` | string | Unique image identifier | | `data.images[].url` | string | Public URL of the image file | | `data.images[].s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data.images[].width` | number | Width in pixels | | `data.images[].height` | number | Height in pixels | | `data.images[].format` | string | File format (PNG, JPEG, WEBP) | | `data.images[].contentType` | string | MIME type of the image | | `data.images[].fileSize` | number \| null | File size in bytes | | `data.images[].imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data.images[].origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data.images[].aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data.images[].generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data.images[].sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data.images[].parentId` | string \| null | Id of the image this one was cropped or resized from | | `data.images[].originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data.images[].title` | string \| null | Image title | | `data.images[].description` | string \| null | Image description | | `data.images[].altText` | string \| null | Alt text | | `data.images[].caption` | string \| null | Caption | | `data.images[].author` | string \| null | Author or photographer | | `data.images[].source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data.images[].license` | string \| null | License (stock images) | | `data.images[].creditLine` | string \| null | Credit line to display (stock images) | | `data.images[].tags` | string[] | Tags associated with the image | | `data.images[].createdAt` | string | ISO 8601 creation timestamp | | `data.images[].updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `data.authors` | Author[] | Assigned authors (only with `include=authors`): `{ id, name, status, avatarUrl, bio, topicKey }` | | `data.categories` | Category[] | Assigned categories (only with `include=categories`): `{ id, name, slug, description, parentId }` | | `data.qualityChecks` | QualityCheck[] | Quality check history, newest first (only with `include=qualityChecks`). Same objects as `GET /stories/{id}/content/quality-checks`. | | `data.seoAudits` | SeoAudit[] | SEO audit history, newest first (only with `include=seoAudits`). Same objects as `GET /stories/{id}/content/seo-audits`. | | `data.translations` | Translation[] | Every language version of this story including itself (only with `include=translations`): `{ code, storyId, default, slug, title }`. `default` marks the original. | | `data.contentSources` | ContentSourceRef[] | Sources the story was written from (only with `include=contentSources`): `{ id, title, link, sourceType, sourceMeta }`. Add `fullContentSources=true` on the detail endpoints for the complete records. | | `data.relatedStories` | RelatedStory[] | Up to 5 published stories of the same Topic Workspace and language that are closest to this one in meaning, closest first: ready for a Read next block. Always returned, no `include` needed. Found when the story is published (within about a minute) and again when a published story gets a new title or summary; empty until then and when no story is close enough. Only stories published right now are listed, with their current title and slug, and never another language version of this story. | | `data.relatedStories[].id` | string | Id of the related story | | `data.relatedStories[].title` | string | Its current title | | `data.relatedStories[].slug` | string | Its slug. Point it at your own story route, like the links inside `content` | | `data.relatedStories[].summary` | string | Its short summary | | `data.relatedStories[].language` | string | Full locale code, always the language of this story | | `data.relatedStories[].publishedAt` | string \| null | ISO 8601 timestamp of its first publication | | `data.relatedStories[].mainImageUrl` | string \| null | URL of its main image, when it has one | | `data.relatedStories[].thumbnail` | string \| null | URL of its thumbnail, often the source publisher's photo | | `data.relatedStories[].score` | number | Similarity to this story from 0 to 1, higher is closer. Listed stories score 0.7 or more | ```json { "data": { "id": "68b9b7c2f1a2b3c4d5e6f700", "title": "AI Revolution in Healthcare", "summary": "How artificial intelligence is transforming medical diagnostics.", "slug": "ai-revolution-in-healthcare", "status": "published", "language": "en-US", "tags": [ "ai", "healthcare", "technology" ], "topicKey": "tech", "parentId": null, "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/thumb_400x225.jpg", "thumbnailImageId": "68b9b7c2f1a2b3c4d5e6f703", "mainImageId": "68b9b7c2f1a2b3c4d5e6f702", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "publishedAt": "2026-08-15T11:45:00.000Z", "firstReported": "2026-08-15T08:00:00.000Z", "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T12:00:00.000Z", "content": "

Overview

A comprehensive look at how AI is being deployed across diagnostics, triage and treatment planning.

", "bulletPoints": [ "AI diagnostics accuracy reaches 95%", "Cost reduction of 40% in pilot hospitals" ], "sentiment": 0.8, "metadata": { "title": "AI Revolution in Healthcare: What Changes in 2026", "description": "AI is transforming diagnostics, triage and treatment planning. Here is what hospitals report.", "keywords": "ai, healthcare, diagnostics" }, "relatedStories": [ { "id": "68b9b7c2f1a2b3c4d5e6f740", "title": "Hospitals Cut Waiting Times with AI Triage", "slug": "hospitals-cut-waiting-times-with-ai-triage", "summary": "Emergency departments using AI triage report shorter waits and fewer missed cases.", "language": "en-US", "publishedAt": "2026-08-12T09:10:00.000Z", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/generated_openai_1788125993651_0.png", "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/thumb_400x225.jpg", "score": 0.8412 }, { "id": "68b9b7c2f1a2b3c4d5e6f741", "title": "Regulators Draft Rules for Diagnostic AI", "slug": "regulators-draft-rules-for-diagnostic-ai", "summary": "A draft framework would require clinical trials before diagnostic models reach patients.", "language": "en-US", "publishedAt": "2026-08-09T15:30:00.000Z", "mainImageUrl": null, "thumbnail": null, "score": 0.7761 } ], "images": [ { "id": "68b9b7c2f1a2b3c4d5e6f701", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "originalUrl": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "width": 1024, "height": 683, "format": "JPEG", "contentType": "image/jpeg", "fileSize": 214532, "imageType": "main", "origin": "source", "aiGenerated": false, "generation": null, "sourceType": "story", "parentId": null, "title": "AI Revolution in Healthcare", "description": null, "altText": null, "caption": null, "author": null, "source": "https://techcrunch.com/2026/07/30/ai-healthcare/", "license": null, "creditLine": null, "tags": [], "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" }, { "id": "68b9b7c2f1a2b3c4d5e6f702", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "originalUrl": null, "width": 1024, "height": 576, "format": "PNG", "contentType": "image/png", "fileSize": 1583022, "imageType": "main", "origin": "generated", "aiGenerated": true, "generation": { "provider": "openai", "model": "gpt-image-1", "prompt": "Editorial illustration of AI-assisted medical diagnostics in a modern hospital" }, "sourceType": "generated", "parentId": null, "title": "AI Generated Image - openai", "description": null, "altText": null, "caption": null, "author": "openai AI", "source": "openai", "license": null, "creditLine": null, "tags": [ "ai-generated", "openai", "healthcare" ], "createdAt": "2026-08-15T11:02:00.000Z", "updatedAt": "2026-08-15T11:02:00.000Z" } ], "authors": [ { "id": "68b9b7c2f1a2b3c4d5e6f720", "name": "Jane Doe", "status": "active", "avatarUrl": "https://cdn.news-factory.app/authors/jane.jpg", "topicKey": "tech" } ], "categories": [ { "id": "68b9b7c2f1a2b3c4d5e6f730", "name": "Health Tech", "slug": "health-tech", "parentId": null } ], "translations": [ { "code": "en-US", "storyId": "68b9b7c2f1a2b3c4d5e6f700", "default": true, "slug": "ai-revolution-in-healthcare", "title": "AI Revolution in Healthcare" }, { "code": "de-DE", "storyId": "68b9b7c2f1a2b3c4d5e6f710", "default": false, "slug": "ki-revolution-im-gesundheitswesen", "title": "KI-Revolution im Gesundheitswesen" }, { "code": "pt-BR", "storyId": "68b9b7c2f1a2b3c4d5e6f711", "default": false, "slug": "revolucao-da-ia-na-saude", "title": "Revolução da IA na saúde" } ] } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700?include=images%2Cauthors%2Ccategories%2Ctranslations" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Update Story
`PATCH /v1/stories/{id}` · operationId `updateStory` · scopes: `stories:write` Update the provided fields and return the fresh story. Arrays such as `tags` replace the existing value. Publishing is a status change: set `status` to `published`. The related stories of a story you publish (or retitle) are found within about a minute, so the response still shows the previous `relatedStories`: read the story again later for the new ones. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The story id | **Request body** (JSON): Fields to update (at least one) | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | Story title | | `summary` | string | no | Short summary | | `content` | string | no | Full HTML body. Link another story of the Topic Workspace with `` | | `bulletPoints` | string[] | no | Key points | | `language` | string | no | Full locale code (default: the Topic Workspace's default language) | | `status` | string | no | draft, pending, approved, rejected, scheduled, published, archived or deleted (default draft) | | `publishedAt` | string (ISO 8601) \| null | no | When the story was published. Set automatically the first time `status` becomes `published`; send a date-time to backdate it, or null to clear it | | `tags` | string[] | no | Tags | | `slug` | string | no | URL slug (generated from the title when omitted) | | `thumbnail` | string | no | Thumbnail URL | | `metadata` | object | no | SEO metadata: title, description, keywords, canonical, ogImage and custom keys | ```json { "title": "AI Revolution in Healthcare: 2026 Update", "status": "published", "tags": [ "ai", "healthcare", "2026" ] } ``` **Responses** - `200` Story updated | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data` | Story | The updated story (no relations) | | `data.id` | string | Unique story identifier | | `data.title` | string | Story title | | `data.summary` | string | Short summary | | `data.content` | string | Full HTML body of the story. Links to other stories are written as `` (a slug, never a URL): point them at your own story route before rendering, as the guide Rendering stories on your site explains | | `data.bulletPoints` | string[] | Key points | | `data.slug` | string | URL-friendly identifier, unique per Topic Workspace and language | | `data.status` | string | draft, pending, approved, rejected, scheduled, published, archived or deleted | | `data.language` | string | Full locale code of the story text, e.g. en-US, de-DE, pt-BR | | `data.tags` | string[] | Tags | | `data.topicKey` | string | Topic Workspace the story belongs to | | `data.parentId` | string \| null | Id of the original story when this one is a translation, else null | | `data.translationStatus` | string \| null | pending, in-progress, completed or needs-update (translations only) | | `data.sentiment` | number | Sentiment score from -1 (negative) to 1 (positive) | | `data.thumbnail` | string | URL of the current thumbnail | | `data.thumbnailImageId` | string | Id of the `images[]` entry backing `thumbnail` | | `data.mainImageId` | string | Id of the current main image | | `data.mainImageUrl` | string | URL of the current main image | | `data.metadata` | object | SEO metadata and custom fields | | `data.metadata.title` | string | Meta title | | `data.metadata.description` | string | Meta description | | `data.metadata.keywords` | string | Meta keywords, comma separated | | `data.metadata.canonical` | string | Canonical URL | | `data.metadata.ogImage` | string | Open Graph image URL | | `data.publishedAt` | string \| null | ISO 8601 timestamp of the first publication: set when `status` first becomes `published`, kept if the story is unpublished later, null until then | | `data.firstReported` | string | ISO 8601 timestamp of the earliest source publication | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string | ISO 8601 timestamp of the last update | | `data.relatedStories` | RelatedStory[] | Up to 5 published stories of the same Topic Workspace and language that are closest to this one in meaning, closest first: ready for a Read next block. Always returned, no `include` needed. Found when the story is published (within about a minute) and again when a published story gets a new title or summary; empty until then and when no story is close enough. Only stories published right now are listed, with their current title and slug, and never another language version of this story. | | `message` | string | Success message | ```json { "success": true, "data": { "id": "68b9b7c2f1a2b3c4d5e6f700", "title": "AI Revolution in Healthcare: 2026 Update", "summary": "How artificial intelligence is transforming medical diagnostics.", "slug": "ai-revolution-in-healthcare", "status": "published", "language": "en-US", "tags": [ "ai", "healthcare", "technology" ], "topicKey": "tech", "parentId": null, "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/thumb_400x225.jpg", "thumbnailImageId": "68b9b7c2f1a2b3c4d5e6f703", "mainImageId": "68b9b7c2f1a2b3c4d5e6f702", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "publishedAt": "2026-08-15T11:45:00.000Z", "firstReported": "2026-08-15T08:00:00.000Z", "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T12:00:00.000Z", "content": "

Overview

A comprehensive look at how AI is being deployed across diagnostics, triage and treatment planning.

", "bulletPoints": [ "AI diagnostics accuracy reaches 95%", "Cost reduction of 40% in pilot hospitals" ], "sentiment": 0.8, "metadata": { "title": "AI Revolution in Healthcare: What Changes in 2026", "description": "AI is transforming diagnostics, triage and treatment planning. Here is what hospitals report.", "keywords": "ai, healthcare, diagnostics" }, "relatedStories": [ { "id": "68b9b7c2f1a2b3c4d5e6f740", "title": "Hospitals Cut Waiting Times with AI Triage", "slug": "hospitals-cut-waiting-times-with-ai-triage", "summary": "Emergency departments using AI triage report shorter waits and fewer missed cases.", "language": "en-US", "publishedAt": "2026-08-12T09:10:00.000Z", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/generated_openai_1788125993651_0.png", "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/thumb_400x225.jpg", "score": 0.8412 }, { "id": "68b9b7c2f1a2b3c4d5e6f741", "title": "Regulators Draft Rules for Diagnostic AI", "slug": "regulators-draft-rules-for-diagnostic-ai", "summary": "A draft framework would require clinical trials before diagnostic models reach patients.", "language": "en-US", "publishedAt": "2026-08-09T15:30:00.000Z", "mainImageUrl": null, "thumbnail": null, "score": 0.7761 } ] }, "message": "Story updated successfully" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found **Example** ```bash curl -X PATCH "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"title":"AI Revolution in Healthcare: 2026 Update","status":"published","tags":["ai","healthcare","2026"]}' ``` ### Delete Story
`DELETE /v1/stories/{id}` · operationId `deleteStory` · scopes: `stories:write` Delete a story permanently. Images that no other story or source uses are removed from storage as well. Translations of the story are not deleted. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The story id | **Responses** - `200` Story deleted | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `message` | string | Success message | ```json { "success": true, "message": "Story deleted successfully" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X DELETE "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Get Story by Slug `GET /v1/stories/slug/{slug}` · operationId `getStoryBySlug` · scopes: `stories:read` The same detail as `GET /stories/{id}`, related stories included, looked up by the story's URL slug. Slugs are unique per Topic Workspace and language, so send the `X-Topic-Key` header when your key spans Topic Workspaces. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | The story slug | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `include` | string | no | Comma separated relations to embed: images, authors, categories, qualityChecks, seoAudits, translations, contentSources. Relations you do not ask for are never returned. `images` returns public Image objects with `origin`, `aiGenerated` and `generation`. | | `fullContentSources` | boolean | no | With `include=contentSources`, return the complete source records instead of the short references | **Responses** - `200` The story | Field | Type | Description | | --- | --- | --- | | `data` | Story | The story with its full content | | `data.id` | string | Unique story identifier | | `data.title` | string | Story title | | `data.summary` | string | Short summary | | `data.content` | string | Full HTML body of the story. Links to other stories are written as `` (a slug, never a URL): point them at your own story route before rendering, as the guide Rendering stories on your site explains | | `data.bulletPoints` | string[] | Key points | | `data.slug` | string | URL-friendly identifier, unique per Topic Workspace and language | | `data.status` | string | draft, pending, approved, rejected, scheduled, published, archived or deleted | | `data.language` | string | Full locale code of the story text, e.g. en-US, de-DE, pt-BR | | `data.tags` | string[] | Tags | | `data.topicKey` | string | Topic Workspace the story belongs to | | `data.parentId` | string \| null | Id of the original story when this one is a translation, else null | | `data.translationStatus` | string \| null | pending, in-progress, completed or needs-update (translations only) | | `data.sentiment` | number | Sentiment score from -1 (negative) to 1 (positive) | | `data.thumbnail` | string | URL of the current thumbnail | | `data.thumbnailImageId` | string | Id of the `images[]` entry backing `thumbnail` | | `data.mainImageId` | string | Id of the current main image | | `data.mainImageUrl` | string | URL of the current main image | | `data.metadata` | object | SEO metadata and custom fields | | `data.metadata.title` | string | Meta title | | `data.metadata.description` | string | Meta description | | `data.metadata.keywords` | string | Meta keywords, comma separated | | `data.metadata.canonical` | string | Canonical URL | | `data.metadata.ogImage` | string | Open Graph image URL | | `data.publishedAt` | string \| null | ISO 8601 timestamp of the first publication: set when `status` first becomes `published`, kept if the story is unpublished later, null until then | | `data.firstReported` | string | ISO 8601 timestamp of the earliest source publication | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string | ISO 8601 timestamp of the last update | | `data.images` | Image[] | All images attached to the story (only with `include=images`). Each item is a public Image object; prefer the ones with `aiGenerated: true` over the publisher's photo. | | `data.images[].id` | string | Unique image identifier | | `data.images[].url` | string | Public URL of the image file | | `data.images[].s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data.images[].width` | number | Width in pixels | | `data.images[].height` | number | Height in pixels | | `data.images[].format` | string | File format (PNG, JPEG, WEBP) | | `data.images[].contentType` | string | MIME type of the image | | `data.images[].fileSize` | number \| null | File size in bytes | | `data.images[].imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data.images[].origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data.images[].aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data.images[].generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data.images[].sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data.images[].parentId` | string \| null | Id of the image this one was cropped or resized from | | `data.images[].originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data.images[].title` | string \| null | Image title | | `data.images[].description` | string \| null | Image description | | `data.images[].altText` | string \| null | Alt text | | `data.images[].caption` | string \| null | Caption | | `data.images[].author` | string \| null | Author or photographer | | `data.images[].source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data.images[].license` | string \| null | License (stock images) | | `data.images[].creditLine` | string \| null | Credit line to display (stock images) | | `data.images[].tags` | string[] | Tags associated with the image | | `data.images[].createdAt` | string | ISO 8601 creation timestamp | | `data.images[].updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `data.authors` | Author[] | Assigned authors (only with `include=authors`): `{ id, name, status, avatarUrl, bio, topicKey }` | | `data.categories` | Category[] | Assigned categories (only with `include=categories`): `{ id, name, slug, description, parentId }` | | `data.qualityChecks` | QualityCheck[] | Quality check history, newest first (only with `include=qualityChecks`). Same objects as `GET /stories/{id}/content/quality-checks`. | | `data.seoAudits` | SeoAudit[] | SEO audit history, newest first (only with `include=seoAudits`). Same objects as `GET /stories/{id}/content/seo-audits`. | | `data.translations` | Translation[] | Every language version of this story including itself (only with `include=translations`): `{ code, storyId, default, slug, title }`. `default` marks the original. | | `data.contentSources` | ContentSourceRef[] | Sources the story was written from (only with `include=contentSources`): `{ id, title, link, sourceType, sourceMeta }`. Add `fullContentSources=true` on the detail endpoints for the complete records. | | `data.relatedStories` | RelatedStory[] | Up to 5 published stories of the same Topic Workspace and language that are closest to this one in meaning, closest first: ready for a Read next block. Always returned, no `include` needed. Found when the story is published (within about a minute) and again when a published story gets a new title or summary; empty until then and when no story is close enough. Only stories published right now are listed, with their current title and slug, and never another language version of this story. | | `data.relatedStories[].id` | string | Id of the related story | | `data.relatedStories[].title` | string | Its current title | | `data.relatedStories[].slug` | string | Its slug. Point it at your own story route, like the links inside `content` | | `data.relatedStories[].summary` | string | Its short summary | | `data.relatedStories[].language` | string | Full locale code, always the language of this story | | `data.relatedStories[].publishedAt` | string \| null | ISO 8601 timestamp of its first publication | | `data.relatedStories[].mainImageUrl` | string \| null | URL of its main image, when it has one | | `data.relatedStories[].thumbnail` | string \| null | URL of its thumbnail, often the source publisher's photo | | `data.relatedStories[].score` | number | Similarity to this story from 0 to 1, higher is closer. Listed stories score 0.7 or more | ```json { "data": { "id": "68b9b7c2f1a2b3c4d5e6f700", "title": "AI Revolution in Healthcare", "summary": "How artificial intelligence is transforming medical diagnostics.", "slug": "ai-revolution-in-healthcare", "status": "published", "language": "en-US", "tags": [ "ai", "healthcare", "technology" ], "topicKey": "tech", "parentId": null, "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/thumb_400x225.jpg", "thumbnailImageId": "68b9b7c2f1a2b3c4d5e6f703", "mainImageId": "68b9b7c2f1a2b3c4d5e6f702", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "publishedAt": "2026-08-15T11:45:00.000Z", "firstReported": "2026-08-15T08:00:00.000Z", "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T12:00:00.000Z", "content": "

Overview

A comprehensive look at how AI is being deployed across diagnostics, triage and treatment planning.

", "bulletPoints": [ "AI diagnostics accuracy reaches 95%", "Cost reduction of 40% in pilot hospitals" ], "sentiment": 0.8, "metadata": { "title": "AI Revolution in Healthcare: What Changes in 2026", "description": "AI is transforming diagnostics, triage and treatment planning. Here is what hospitals report.", "keywords": "ai, healthcare, diagnostics" }, "relatedStories": [ { "id": "68b9b7c2f1a2b3c4d5e6f740", "title": "Hospitals Cut Waiting Times with AI Triage", "slug": "hospitals-cut-waiting-times-with-ai-triage", "summary": "Emergency departments using AI triage report shorter waits and fewer missed cases.", "language": "en-US", "publishedAt": "2026-08-12T09:10:00.000Z", "mainImageUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/generated_openai_1788125993651_0.png", "thumbnail": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f740/thumb_400x225.jpg", "score": 0.8412 }, { "id": "68b9b7c2f1a2b3c4d5e6f741", "title": "Regulators Draft Rules for Diagnostic AI", "slug": "regulators-draft-rules-for-diagnostic-ai", "summary": "A draft framework would require clinical trials before diagnostic models reach patients.", "language": "en-US", "publishedAt": "2026-08-09T15:30:00.000Z", "mainImageUrl": null, "thumbnail": null, "score": 0.7761 } ], "images": [ { "id": "68b9b7c2f1a2b3c4d5e6f701", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "originalUrl": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "width": 1024, "height": 683, "format": "JPEG", "contentType": "image/jpeg", "fileSize": 214532, "imageType": "main", "origin": "source", "aiGenerated": false, "generation": null, "sourceType": "story", "parentId": null, "title": "AI Revolution in Healthcare", "description": null, "altText": null, "caption": null, "author": null, "source": "https://techcrunch.com/2026/07/30/ai-healthcare/", "license": null, "creditLine": null, "tags": [], "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" }, { "id": "68b9b7c2f1a2b3c4d5e6f702", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "originalUrl": null, "width": 1024, "height": 576, "format": "PNG", "contentType": "image/png", "fileSize": 1583022, "imageType": "main", "origin": "generated", "aiGenerated": true, "generation": { "provider": "openai", "model": "gpt-image-1", "prompt": "Editorial illustration of AI-assisted medical diagnostics in a modern hospital" }, "sourceType": "generated", "parentId": null, "title": "AI Generated Image - openai", "description": null, "altText": null, "caption": null, "author": "openai AI", "source": "openai", "license": null, "creditLine": null, "tags": [ "ai-generated", "openai", "healthcare" ], "createdAt": "2026-08-15T11:02:00.000Z", "updatedAt": "2026-08-15T11:02:00.000Z" } ], "authors": [ { "id": "68b9b7c2f1a2b3c4d5e6f720", "name": "Jane Doe", "status": "active", "avatarUrl": "https://cdn.news-factory.app/authors/jane.jpg", "topicKey": "tech" } ], "categories": [ { "id": "68b9b7c2f1a2b3c4d5e6f730", "name": "Health Tech", "slug": "health-tech", "parentId": null } ], "translations": [ { "code": "en-US", "storyId": "68b9b7c2f1a2b3c4d5e6f700", "default": true, "slug": "ai-revolution-in-healthcare", "title": "AI Revolution in Healthcare" }, { "code": "de-DE", "storyId": "68b9b7c2f1a2b3c4d5e6f710", "default": false, "slug": "ki-revolution-im-gesundheitswesen", "title": "KI-Revolution im Gesundheitswesen" }, { "code": "pt-BR", "storyId": "68b9b7c2f1a2b3c4d5e6f711", "default": false, "slug": "revolucao-da-ia-na-saude", "title": "Revolução da IA na saúde" } ] } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/stories/slug/ai-revolution-in-healthcare?include=images%2Ctranslations" \ -H "Authorization: Bearer nfk_xxx..." ``` ### List Story Media
`GET /v1/stories/{storyId}/media` · operationId `listStoryMedia` · scopes: `stories:read` All images attached to a story as public Image objects. Each carries `origin`, `aiGenerated` and `generation`, so you can tell the images News Factory generated (safe to publish as your own) from the publisher's photo (`origin: "source"`). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Responses** - `200` The story's images | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data` | Image[] | Public Image objects. Filter on `aiGenerated` or `origin` to pick what to publish. | | `data[].id` | string | Unique image identifier | | `data[].url` | string | Public URL of the image file | | `data[].s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data[].width` | number | Width in pixels | | `data[].height` | number | Height in pixels | | `data[].format` | string | File format (PNG, JPEG, WEBP) | | `data[].contentType` | string | MIME type of the image | | `data[].fileSize` | number \| null | File size in bytes | | `data[].imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data[].origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data[].aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data[].generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data[].sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data[].parentId` | string \| null | Id of the image this one was cropped or resized from | | `data[].originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data[].title` | string \| null | Image title | | `data[].description` | string \| null | Image description | | `data[].altText` | string \| null | Alt text | | `data[].caption` | string \| null | Caption | | `data[].author` | string \| null | Author or photographer | | `data[].source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data[].license` | string \| null | License (stock images) | | `data[].creditLine` | string \| null | Credit line to display (stock images) | | `data[].tags` | string[] | Tags associated with the image | | `data[].createdAt` | string | ISO 8601 creation timestamp | | `data[].updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `meta.count` | number | Number of images | ```json { "success": true, "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f701", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "originalUrl": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "width": 1024, "height": 683, "format": "JPEG", "contentType": "image/jpeg", "fileSize": 214532, "imageType": "main", "origin": "source", "aiGenerated": false, "generation": null, "sourceType": "story", "parentId": null, "title": "AI Revolution in Healthcare", "description": null, "altText": null, "caption": null, "author": null, "source": "https://techcrunch.com/2026/07/30/ai-healthcare/", "license": null, "creditLine": null, "tags": [], "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" }, { "id": "68b9b7c2f1a2b3c4d5e6f702", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "originalUrl": null, "width": 1024, "height": 576, "format": "PNG", "contentType": "image/png", "fileSize": 1583022, "imageType": "main", "origin": "generated", "aiGenerated": true, "generation": { "provider": "openai", "model": "gpt-image-1", "prompt": "Editorial illustration of AI-assisted medical diagnostics in a modern hospital" }, "sourceType": "generated", "parentId": null, "title": "AI Generated Image - openai", "description": null, "altText": null, "caption": null, "author": "openai AI", "source": "openai", "license": null, "creditLine": null, "tags": [ "ai-generated", "openai", "healthcare" ], "createdAt": "2026-08-15T11:02:00.000Z", "updatedAt": "2026-08-15T11:02:00.000Z" } ], "meta": { "count": 2 } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/media" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Upload Media `POST /v1/stories/{storyId}/media` · operationId `uploadStoryMedia` · scopes: `stories:write` Store an image and attach it to the story. Send either a base64 `fileData` with `fileName` and `mimeType`, or a `url` to import from. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Either fileData (with fileName and mimeType) or url | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | Image title | | `description` | string | no | Image description | | `tags` | string[] | no | Tags | | `imageType` | string | no | Role on the story (default content) | | `fileData` | file | no | Base64 encoded image (PNG, JPEG, WEBP, GIF). 10 MB request limit. | | `fileName` | string | no | Original file name (required with fileData) | | `mimeType` | string | no | MIME type (required with fileData) | | `url` | string | no | Public image URL to import instead of fileData | ```json { "title": "Hero image", "tags": [ "photo", "hero" ], "imageType": "content", "url": "https://example.com/image.jpg" } ``` **Responses** - `201` Image stored and attached | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data` | Image | The public Image object (`origin: "upload"`, `aiGenerated: false`) | | `data.id` | string | Unique image identifier | | `data.url` | string | Public URL of the image file | | `data.s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data.width` | number | Width in pixels | | `data.height` | number | Height in pixels | | `data.format` | string | File format (PNG, JPEG, WEBP) | | `data.contentType` | string | MIME type of the image | | `data.fileSize` | number \| null | File size in bytes | | `data.imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data.origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data.aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data.generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data.sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data.parentId` | string \| null | Id of the image this one was cropped or resized from | | `data.originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data.title` | string \| null | Image title | | `data.description` | string \| null | Image description | | `data.altText` | string \| null | Alt text | | `data.caption` | string \| null | Caption | | `data.author` | string \| null | Author or photographer | | `data.source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data.license` | string \| null | License (stock images) | | `data.creditLine` | string \| null | Credit line to display (stock images) | | `data.tags` | string[] | Tags associated with the image | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `message` | string | Success message | ```json { "success": true, "data": { "id": "68b9b7c2f1a2b3c4d5e6f705", "url": "https://cdn.news-factory.app/organizations/acme/hero.jpg", "s3Url": "https://cdn.news-factory.app/organizations/acme/hero.jpg", "originalUrl": null, "width": 1024, "height": 683, "format": "JPEG", "contentType": "image/jpeg", "fileSize": 214532, "imageType": "content", "origin": "upload", "aiGenerated": false, "generation": null, "sourceType": "user-upload", "parentId": null, "title": "Hero image", "description": null, "altText": null, "caption": null, "author": null, "source": null, "license": null, "creditLine": null, "tags": [ "photo", "hero" ], "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" }, "message": "Image imported from URL and attached to story" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/media" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"title":"Hero image","tags":["photo","hero"],"imageType":"content","url":"https://example.com/image.jpg"}' ``` ### Import Image from URL `POST /v1/stories/{storyId}/media/from-url` · operationId `importImageUrl` · scopes: `stories:write` Download an image from a public URL, store it and attach it to the story. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): The URL and optional metadata | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | yes | Public image URL | | `title` | string | no | Image title | | `description` | string | no | Image description | | `tags` | string[] | no | Tags | | `imageType` | string | no | Role on the story (default content) | ```json { "url": "https://example.com/image.jpg", "title": "Hero image", "imageType": "main" } ``` **Responses** - `201` Image stored and attached | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data` | Image | The public Image object (`origin: "upload"`, `aiGenerated: false`) | | `data.id` | string | Unique image identifier | | `data.url` | string | Public URL of the image file | | `data.s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data.width` | number | Width in pixels | | `data.height` | number | Height in pixels | | `data.format` | string | File format (PNG, JPEG, WEBP) | | `data.contentType` | string | MIME type of the image | | `data.fileSize` | number \| null | File size in bytes | | `data.imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data.origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data.aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data.generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data.sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data.parentId` | string \| null | Id of the image this one was cropped or resized from | | `data.originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data.title` | string \| null | Image title | | `data.description` | string \| null | Image description | | `data.altText` | string \| null | Alt text | | `data.caption` | string \| null | Caption | | `data.author` | string \| null | Author or photographer | | `data.source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data.license` | string \| null | License (stock images) | | `data.creditLine` | string \| null | Credit line to display (stock images) | | `data.tags` | string[] | Tags associated with the image | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `message` | string | Success message | ```json { "success": true, "data": { "id": "68b9b7c2f1a2b3c4d5e6f705", "url": "https://cdn.news-factory.app/organizations/acme/hero.jpg", "s3Url": "https://cdn.news-factory.app/organizations/acme/hero.jpg", "originalUrl": null, "width": 1024, "height": 683, "format": "JPEG", "contentType": "image/jpeg", "fileSize": 214532, "imageType": "content", "origin": "upload", "aiGenerated": false, "generation": null, "sourceType": "user-upload", "parentId": null, "title": "Hero image", "description": null, "altText": null, "caption": null, "author": null, "source": null, "license": null, "creditLine": null, "tags": [ "photo", "hero" ], "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" }, "message": "Image imported from URL and attached to story" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/media/from-url" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/image.jpg","title":"Hero image","imageType":"main"}' ``` ### Generate Image (AI) `POST /v1/stories/{storyId}/media/generate` · operationId `generateImage` · scopes: `ai:generate` + `stories:write` Generate an image with an AI model and attach it to the story. Providers: OpenAI (GPT Image), Google Gemini and Ideogram. Story context (title, summary) is added to the prompt unless `includeStoryContext` is false. The saved image is flagged `aiGenerated: true` with `origin: "generated"`; read it back with `GET /stories/{storyId}/media`. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Generation parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `prompt` | string | yes | What to generate | | `provider` | string | yes | AI provider | | `aspectRatio` | string | no | Aspect ratio | | `quality` | string | no | Quality level (default high) | | `style` | string | no | Visual style (default photographic) | | `negativePrompt` | string | no | What to avoid | | `numImages` | string | no | Number of images (default 1) | | `imageType` | string | no | Role of the generated image (default main) | | `includeStoryContext` | string | no | Add the story title and summary to the prompt (default true) | ```json { "prompt": "A photorealistic editorial photograph of a hospital diagnostics lab", "provider": "openai", "aspectRatio": "16:9", "quality": "high" } ``` **Responses** - `200` Image generated and attached | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.storyId` | string | The story | | `data.provider` | string | Provider used | | `data.model` | string \| null | Model used | | `data.processingTimeMs` | number \| null | Generation time | | `data.images` | object[] | Generated images: `{ imageId, url, width, height, format, prompt }`. Fetch the full public Image objects with `GET /stories/{storyId}/media`. | | `message` | string | Success message | ```json { "success": true, "data": { "storyId": "68b9b7c2f1a2b3c4d5e6f700", "provider": "openai", "model": "gpt-image-1", "processingTimeMs": 4500, "images": [ { "imageId": "68b9b7c2f1a2b3c4d5e6f702", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/generated_openai_1788425993651_0.png", "width": 1792, "height": 1024, "format": "png", "prompt": "A photorealistic editorial photograph of a hospital diagnostics lab. Story: AI Revolution in Healthcare" } ] }, "message": "Image generated successfully" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/media/generate" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"prompt":"A photorealistic editorial photograph of a hospital diagnostics lab","provider":"openai","aspectRatio":"16:9","quality":"high"}' ``` ### Set Thumbnail `POST /v1/stories/{storyId}/media/thumbnail` · operationId `setThumbnail` · scopes: `stories:write` Generate the story thumbnail from an attached image (`imageId`), from any image URL (`imageUrl`), or from the story's first attached image when both are omitted. The previous thumbnail image is deleted when no other story uses it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): All fields optional | Name | Type | Required | Description | | --- | --- | --- | --- | | `imageId` | string | no | Attached image to crop from | | `imageUrl` | string | no | Image URL to crop from | | `preset` | string | no | Size preset (default small, 400 by 225) | | `width` | number | no | Custom width in pixels (overrides the preset) | | `height` | number | no | Custom height in pixels | | `quality` | number | no | JPEG quality 1 to 100 (default 80) | ```json { "imageId": "68b9b7c2f1a2b3c4d5e6f702", "preset": "standard", "quality": 85 } ``` **Responses** - `200` Thumbnail set | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.storyId` | string | The story | | `data.thumbnailUrl` | string | Public URL of the new thumbnail (also written to the story's `thumbnail`) | | `data.thumbnailImageId` | string | Id of the new thumbnail Image (also written to `thumbnailImageId`) | | `data.width` | number | Width in pixels | | `data.height` | number | Height in pixels | | `data.format` | string | File format | | `data.fileSize` | number | Bytes | | `data.preset` | string | Preset used | | `data.sourceImage` | string | The image id or URL the thumbnail was cropped from | | `data.previousThumbnail` | object \| null | `{ imageId, deleted }` when a previous thumbnail existed | | `message` | string | Success message | ```json { "success": true, "data": { "storyId": "68b9b7c2f1a2b3c4d5e6f700", "thumbnailUrl": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/thumb_800x450.jpg", "thumbnailImageId": "68b9b7c2f1a2b3c4d5e6f703", "width": 800, "height": 450, "format": "jpeg", "fileSize": 61234, "preset": "standard", "sourceImage": "68b9b7c2f1a2b3c4d5e6f702", "previousThumbnail": { "imageId": "68b9b7c2f1a2b3c4d5e6f6ff", "deleted": true } }, "message": "Thumbnail set successfully" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/media/thumbnail" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"imageId":"68b9b7c2f1a2b3c4d5e6f702","preset":"standard","quality":85}' ``` ### Remove Thumbnail `DELETE /v1/stories/{storyId}/media/thumbnail` · operationId `removeThumbnail` · scopes: `stories:write` Clear the story's `thumbnail` and `thumbnailImageId`. The image record itself is kept. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Responses** - `200` Thumbnail cleared | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `message` | string | Success message | ```json { "success": true, "message": "Thumbnail removed" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found **Example** ```bash curl -X DELETE "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/media/thumbnail" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Attach Media `POST /v1/stories/{storyId}/media/{imageId}` · operationId `attachStoryMedia` · scopes: `stories:write` Attach an image that already exists in the organization (for example one uploaded to another story) to this story. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | | `imageId` | string | yes | The image id | **Responses** - `200` Image attached | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.storyId` | string | The story | | `data.imageId` | string | The attached image | | `data.imageIds` | string[] | All image ids now attached to the story | | `message` | string | Success message | ```json { "success": true, "data": { "storyId": "68b9b7c2f1a2b3c4d5e6f700", "imageId": "68b9b7c2f1a2b3c4d5e6f702", "imageIds": [ "68b9b7c2f1a2b3c4d5e6f701", "68b9b7c2f1a2b3c4d5e6f702" ] }, "message": "Image attached to story" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story or image not found **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/media/68b9b7c2f1a2b3c4d5e6f702" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Detach Media `DELETE /v1/stories/{storyId}/media/{imageId}` · operationId `removeStoryMedia` · scopes: `stories:write` Detach an image from the story. The image record is kept and stays attached to any other story that uses it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | | `imageId` | string | yes | The image id | **Responses** - `200` Image detached | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `message` | string | Success message | ```json { "success": true, "message": "Image removed from story" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story or image not found **Example** ```bash curl -X DELETE "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/media/68b9b7c2f1a2b3c4d5e6f702" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Suggest Titles `POST /v1/stories/{storyId}/content/suggest-titles` · operationId `suggestTitles` · scopes: `ai:generate` + `stories:read` AI title suggestions grounded in the story's content sources. The story needs at least one content source. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `count` | number | no | How many suggestions (1 to 10, default 3) | | `language` | string | no | Language of the titles as a full locale code (default: the story language) | | `titles` | string[] | no | Titles to avoid repeating | ```json { "count": 3, "language": "en-US" } ``` **Responses** - `200` Suggestions | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `storyId` | string | The story | | `storyTitle` | string | Current title | | `count` | number | Suggestions returned | | `titleSuggestions` | string[] | Suggested titles | | `language` | string | Language used | | `model` | string | LLM model used | ```json { "success": true, "storyId": "68b9b7c2f1a2b3c4d5e6f700", "storyTitle": "AI Revolution in Healthcare", "count": 3, "titleSuggestions": [ "AI Revolution Reshapes Modern Healthcare", "How Artificial Intelligence Is Transforming Medicine", "The Future of Healthcare: AI-Driven Diagnostics" ], "language": "en-US", "model": "claude-sonnet-5" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/suggest-titles" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"count":3,"language":"en-US"}' ``` ### Suggest Summary `POST /v1/stories/{storyId}/content/suggest-summary` · operationId `suggestSummary` · scopes: `ai:generate` + `stories:read` AI summary suggestions grounded in the story's content sources. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `count` | number | no | How many suggestions (1 to 5, default 3) | | `language` | string | no | Language of the summaries as a full locale code (default: the story language) | ```json { "count": 3, "language": "en-US" } ``` **Responses** - `200` Suggestions | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `storyId` | string | The story | | `storySummary` | string | Current summary | | `count` | number | Suggestions returned | | `summarySuggestions` | string[] | Suggested summaries | | `language` | string | Language used | | `model` | string | LLM model used | ```json { "success": true, "storyId": "68b9b7c2f1a2b3c4d5e6f700", "storySummary": "How artificial intelligence is transforming medical diagnostics.", "count": 2, "summarySuggestions": [ "Hospitals across Europe report faster triage and lower costs after adopting AI diagnostics.", "AI diagnostic tools now reach 95% accuracy in pilot programmes, cutting costs by 40%." ], "language": "en-US", "model": "claude-sonnet-5" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/suggest-summary" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"count":3,"language":"en-US"}' ``` ### Suggest SEO Title `POST /v1/stories/{storyId}/content/suggest-seo-title` · operationId `suggestSeoTitle` · scopes: `ai:generate` + `stories:read` Search engine optimised title suggestions (under 60 characters, keyword first). Write the pick to `metadata.title` with `PATCH /stories/{id}`. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `count` | number | no | How many suggestions (1 to 5, default 3) | | `language` | string | no | Language of the titles as a full locale code (default: the story language) | ```json { "count": 3 } ``` **Responses** - `200` Suggestions | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `count` | number | Suggestions returned | | `seoTitleSuggestions` | string[] | Suggested SEO titles | | `language` | string | Language used | ```json { "success": true, "count": 3, "seoTitleSuggestions": [ "AI in Healthcare 2026: Diagnostics, Costs and Results", "AI Diagnostics Hit 95% Accuracy in Hospital Pilots", "How AI Is Cutting Hospital Costs by 40%" ], "language": "en-US" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/suggest-seo-title" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"count":3}' ``` ### Suggest Meta Description `POST /v1/stories/{storyId}/content/suggest-meta-description` · operationId `suggestMetaDescription` · scopes: `ai:generate` + `stories:read` Meta description suggestions (under 160 characters). Write the pick to `metadata.description` with `PATCH /stories/{id}`. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `count` | number | no | How many suggestions (1 to 5, default 3) | | `language` | string | no | Language of the descriptions as a full locale code (default: the story language) | ```json { "count": 3 } ``` **Responses** - `200` Suggestions | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `count` | number | Suggestions returned | | `metaDescriptionSuggestions` | string[] | Suggested meta descriptions | | `language` | string | Language used | ```json { "success": true, "count": 2, "metaDescriptionSuggestions": [ "AI diagnostics reach 95% accuracy and cut hospital costs by 40%. See what the European pilots found.", "How hospitals use AI for triage and diagnostics, and what it means for patients in 2026." ], "language": "en-US" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/suggest-meta-description" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"count":3}' ``` ### Generate Social Post `POST /v1/stories/{storyId}/content/social-post/{platform}` · operationId `generateSocialPost` · scopes: `ai:generate` + `stories:read` Platform specific social media posts for the story. Generated posts are also saved on the story for the app's social tab. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | | `platform` | string (`facebook`, `linkedin`, `x`, `instagram`) | yes | Target platform | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `count` | number | no | How many suggestions (1 to 5, default 3) | | `targetUrl` | string | no | Link to include in the posts | | `additionalInstructions` | string | no | Tone or content instructions | ```json { "count": 2, "targetUrl": "https://news.example.com/ai-revolution-in-healthcare" } ``` **Responses** - `200` Posts | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `storyId` | string | The story | | `storyTitle` | string | Current title | | `platform` | string | Platform | | `count` | number | Posts returned | | `targetURL` | string \| null | Link included | | `socialMediaPosts` | string[] | Generated posts | | `model` | string | LLM model used | | `additionalInstructions` | string \| null | Instructions applied | ```json { "success": true, "storyId": "68b9b7c2f1a2b3c4d5e6f700", "storyTitle": "AI Revolution in Healthcare", "platform": "linkedin", "count": 2, "targetURL": "https://news.example.com/ai-revolution-in-healthcare", "socialMediaPosts": [ "AI is no longer the future of healthcare. It is the present. From diagnostics to treatment planning, here is what European hospitals report. https://news.example.com/ai-revolution-in-healthcare", "95% diagnostic accuracy. 40% lower costs. The numbers behind AI in hospitals: https://news.example.com/ai-revolution-in-healthcare" ], "model": "claude-sonnet-5", "additionalInstructions": null } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/social-post/linkedin" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"count":2,"targetUrl":"https://news.example.com/ai-revolution-in-healthcare"}' ``` ### Translate Story `POST /v1/stories/{storyId}/content/translate` · operationId `translateStory` · scopes: `ai:generate` + `stories:write` Create a translated copy of the story as a new story linked through `parentId`. The target must be one of the organization's supported languages, given as a full locale code. Translating a language that already exists answers 409. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Target language and options | Name | Type | Required | Description | | --- | --- | --- | --- | | `languageCode` | string | yes | Target language as a full locale code (de-DE, es-ES, pt-BR, ...) | | `effort` | string | no | Model effort (default normal) | ```json { "languageCode": "de-DE" } ``` **Responses** - `200` Translation created | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `storyId` | string | Id of the new translated story | | `translatedStory` | object | `{ id, title, language, slug }` of the new story. Load it with `GET /stories/{id}`. | ```json { "success": true, "storyId": "68b9b7c2f1a2b3c4d5e6f710", "translatedStory": { "id": "68b9b7c2f1a2b3c4d5e6f710", "title": "KI-Revolution im Gesundheitswesen", "language": "de-DE", "slug": "ki-revolution-im-gesundheitswesen" } } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` Forbidden: the credential cannot act on this organization or Topic Workspace Or: The API key lacks a scope this endpoint needs - `404` Story not found - `409` Conflict - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/translate" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"languageCode":"de-DE"}' ``` ### Run Quality Check `POST /v1/stories/{storyId}/content/quality-check` · operationId `qualityCheck` · scopes: `ai:generate` + `stories:read` Run the editorial quality check: fact check against the content sources, writing quality, and topic match. The result is stored in the story's history (`GET /stories/{storyId}/content/quality-checks`) and each finding can be applied with the apply-fix endpoint. Stories under `minWords` words fail without calling the model. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `effort` | string | no | Model effort (default normal) | | `language` | string | no | Language of the report (verdict and explanations) as a full locale code (default: the story language) | | `minWords` | number | no | Minimum story length (default 100) | | `factCheck` | object | no | `{ enabled, customInstructions }` for the fact check (default enabled) | | `writingQuality` | object | no | `{ enabled, customInstructions }` for the writing quality check (default enabled) | | `topicMatch` | object | no | `{ enabled, customInstructions }` for the topic match check (default enabled) | | `globalInstructions` | string | no | Instructions applied to every check | ```json { "effort": "normal", "language": "en-US" } ``` **Responses** - `200` Quality report | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `storyId` | string | The story | | `overall` | string | pass, warn or fail | | `summary` | string | Short verdict in the report language | | `minimumContentOk` | boolean | Whether the story met `minWords` | | `wordCounts.story` | number | Story word count | | `wordCounts.minRequired` | number | Minimum applied | | `wordCounts.sources` | object[] | `{ id, wordCount }` per content source | | `qualityFindings.summary` | string | Report summary | | `qualityFindings.factCheck.findings` | object[] | `{ field, severity, claim, sourceEvidence, action, suggestedFix }` | | `qualityFindings.writingQuality.score` | number | 0 to 100 | | `qualityFindings.writingQuality.findings` | object[] | `{ field, severity, category, excerpt, rewrite }` | | `qualityFindings.topicMatch` | object | `{ expectedTopicKey, matches, modelTopicKey, confidence, recommendedAction, details }` | | `findings` | object | Legacy flat shape: `discrepancies[]`, `writingQuality`, `topicMatch` | ```json { "success": true, "storyId": "68b9b7c2f1a2b3c4d5e6f700", "wordCounts": { "story": 640, "minRequired": 100, "sources": [ { "id": "68b9b7c2f1a2b3c4d5e6f760", "wordCount": 1250 } ] }, "minimumContentOk": true, "qualityFindings": { "summary": "Two claims need a source and one paragraph repeats the lead.", "factCheck": { "findings": [ { "field": "content", "severity": "high", "claim": "AI diagnostics accuracy reaches 99%", "sourceEvidence": "The source reports 95% accuracy in the pilot.", "action": "rewrite", "suggestedFix": "AI diagnostics accuracy reaches 95%" } ] }, "writingQuality": { "score": 78, "findings": [ { "field": "content", "severity": "low", "category": "repetition", "excerpt": "AI is transforming healthcare.", "rewrite": "" } ] }, "topicMatch": { "expectedTopicKey": "tech", "matches": true, "modelTopicKey": "tech", "confidence": 92, "recommendedAction": "keep", "details": "The story is about AI in healthcare, which matches the technology topic." } }, "findings": { "discrepancies": [ { "description": "AI diagnostics accuracy reaches 99% (source: 95%)", "severity": "high" } ], "writingQuality": { "score": 78, "issues": [ "Repeated lead paragraph" ], "flags": [] }, "topicMatch": { "confidence": 92, "details": "Matches the technology topic." } }, "overall": "warn", "summary": "Two claims need a source and one paragraph repeats the lead." } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/quality-check" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"effort":"normal","language":"en-US"}' ``` ### Quality Check History `GET /v1/stories/{storyId}/content/quality-checks` · operationId `listQualityChecks` · scopes: `stories:read` Every quality check that ran on the story, newest first, including checks run by workflows. The same records are available inline with `include=qualityChecks` on the story endpoints. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | **Responses** - `200` History | Field | Type | Description | | --- | --- | --- | | `data` | QualityCheck[] | Quality check records | | `data[].id` | string | Record id | | `data[].storyId` | string | Story the check ran on | | `data[].overall` | string | pass, warn or fail | | `data[].summary` | string | Short verdict written in the report language | | `data[].qualityFindings` | object | Modular findings: `factCheck.findings[]`, `writingQuality.score` and `findings[]`, `topicMatch`. Each finding can be applied with `POST /stories/{id}/content/quality-check/apply-fix` | | `data[].discrepancies` | object[] | Legacy flat list of `{ description, severity }` (kept for older integrations) | | `data[].writingQuality` | object | Legacy `{ score, issues[], flags[] }` | | `data[].topicMatch` | object | Legacy `{ confidence, details }` | | `data[].effort` | string | normal or high | | `data[].checksRun` | string[] | Which checks were enabled: factCheck, writingQuality, topicMatch | | `data[].provider` | string | LLM provider used | | `data[].model` | string | LLM model used | | `data[].credits` | number | Credits charged | | `data[].createdAt` | string | ISO 8601 timestamp | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | | `meta.storyId` | string | The story | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f740", "storyId": "68b9b7c2f1a2b3c4d5e6f700", "overall": "warn", "summary": "Two claims need a source and one paragraph repeats the lead.", "qualityFindings": { "summary": "Two claims need a source and one paragraph repeats the lead.", "factCheck": { "findings": [ { "field": "content", "severity": "high", "claim": "AI diagnostics accuracy reaches 99%", "sourceEvidence": "The source reports 95% accuracy in the pilot.", "action": "rewrite", "suggestedFix": "AI diagnostics accuracy reaches 95%" } ] }, "writingQuality": { "score": 78, "findings": [ { "field": "content", "severity": "low", "category": "repetition", "excerpt": "AI is transforming healthcare.", "rewrite": "" } ] }, "topicMatch": { "expectedTopicKey": "tech", "matches": true, "modelTopicKey": "tech", "confidence": 92, "recommendedAction": "keep", "details": "The story is about AI in healthcare, which matches the technology topic." } }, "discrepancies": [ { "description": "AI diagnostics accuracy reaches 99% (source: 95%)", "severity": "high" } ], "writingQuality": { "score": 78, "issues": [ "Repeated lead paragraph" ], "flags": [] }, "topicMatch": { "confidence": 92, "details": "Matches the technology topic." }, "effort": "normal", "checksRun": [ "factCheck", "writingQuality", "topicMatch" ], "provider": "anthropic", "model": "claude-sonnet-5", "credits": 4, "createdAt": "2026-08-16T09:00:00.000Z" } ], "meta": { "page": 0, "limit": 20, "hasMore": false, "storyId": "68b9b7c2f1a2b3c4d5e6f700" } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/quality-checks" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Apply Quality Fix `POST /v1/stories/{storyId}/content/quality-check/apply-fix` · operationId `applyQualityFix` · scopes: `ai:generate` + `stories:write` Apply one finding from a quality check to the story. `direct` mode replaces the flagged text without a model; `rewrite` mode lets the model rewrite the field around the finding (costs credits). Topic findings with `recommendedAction: reassign` move the story to the suggested Topic Workspace. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): The finding and the mode | Name | Type | Required | Description | | --- | --- | --- | --- | | `finding` | object | yes | A finding object from `qualityFindings`, with a `type` of factCheck, writingQuality or topicMatch | | `mode` | string | no | direct (default) or rewrite | | `effort` | string | no | Model effort for rewrite mode | ```json { "finding": { "type": "factCheck", "field": "content", "severity": "high", "claim": "AI diagnostics accuracy reaches 99%", "sourceEvidence": "The source reports 95% accuracy.", "action": "rewrite", "suggestedFix": "AI diagnostics accuracy reaches 95%" }, "mode": "direct" } ``` **Responses** - `200` Result | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `storyId` | string | The story | | `applied` | boolean | False when the text was already fixed or the claim was not found | | `method` | string | direct or rewrite: | | `field` | string | Field changed: title, summary, content or bulletPoints[n] | | `before` | string | Field value before | | `after` | string | Field value after | ```json { "success": true, "storyId": "68b9b7c2f1a2b3c4d5e6f700", "applied": true, "method": "direct", "field": "content", "before": "

AI diagnostics accuracy reaches 99% in the pilot.

", "after": "

AI diagnostics accuracy reaches 95% in the pilot.

" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/quality-check/apply-fix" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"finding":{"type":"factCheck","field":"content","severity":"high","claim":"AI diagnostics accuracy reaches 99%","sourceEvidence":"The source reports 95% accuracy.","action":"rewrite","suggestedFix":"AI diagnostics accuracy reaches 95%"},"mode":"direct"}' ``` ### Run SEO Audit `POST /v1/stories/{storyId}/content/seo-audit` · operationId `seoAudit` · scopes: `ai:generate` + `stories:read` Audit the story for search and answer engines: on-page checks, keyword placement against the Topic Workspace's SEO keywords, internal link candidates and AEO signals (direct answer, question headings, FAQs, entities). A model drafts concrete fixes unless `skipSuggestions` is true (then the audit is free). The report is stored in the story's history and each `fix` can be applied with the apply-fix endpoint. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `effort` | string | no | Model effort (default normal) | | `language` | string | no | Language of the report summary as a full locale code (default: the story language) | | `skipSuggestions` | string | no | Skip the model pass and return the deterministic checks only (no credits) | | `seoConfig` | object | no | `{ groups: { onPage, keywords, links, aeo: { enabled } }, globalInstructions, maxLinkCandidates }`. All groups default to enabled. | ```json { "effort": "normal", "language": "en-US" } ``` **Responses** - `200` Audit report | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `storyId` | string | The story | | `overallScore` | number | 0 to 100 | | `overall` | string | pass, warn or fail | | `summary` | string | Short summary in the report language | | `credits` | number | Credits charged | | `report.overallScore` | number | 0 to 100 (also at the top level) | | `report.overall` | string | pass, warn or fail (also at the top level) | | `report.summary` | string | Short summary in the report language (also at the top level) | | `report.storySnapshot` | object | Title, SEO title, meta description, slug, word count, headings, images, locale, topic | | `report.checks` | object[] | `{ id, group, status, impact, effort, data?, fix? }`. Apply a `fix` with the apply-fix endpoint. | | `report.keywords` | object[] | `{ term, primary, bodyCount, present[], suggestion? }` per topic keyword | | `report.linkCandidates` | object[] | Stories worth linking: `{ storyId, title, slug, relevance, anchor, paragraphIndex }` | | `report.aeo` | object | Answer engine signals: direct answer, question headings, FAQs, entities, cited claims | | `report.groupScores` | object | Per group 0 to 100 | | `report.suggestionsAvailable` | boolean | Whether the model pass ran | ```json { "success": true, "storyId": "68b9b7c2f1a2b3c4d5e6f700", "report": { "storySnapshot": { "title": "AI Revolution in Healthcare", "seoTitle": "", "metaDescription": "", "slug": "ai-revolution-in-healthcare", "wordCount": 640, "locale": "en-US", "topicKey": "tech" }, "checks": [ { "id": "seoTitlePresent", "group": "onPage", "status": "fail", "impact": "high", "effort": "instant", "fix": { "kind": "setMetadataField", "target": "title", "after": "AI Revolution in Healthcare: What Changes in 2026" } }, { "id": "metaDescriptionLength", "group": "onPage", "status": "warn", "impact": "medium", "effort": "instant" } ], "keywords": [ { "term": "ai diagnostics", "primary": true, "bodyCount": 3, "present": [ "h1", "body" ], "suggestion": { "placement": "seoTitle", "before": "", "after": "AI diagnostics" } } ], "linkCandidates": [ { "storyId": "68b9b7c2f1a2b3c4d5e6f790", "title": "Hospitals adopt AI triage", "slug": "hospitals-adopt-ai-triage", "relevance": 81, "inboundLinks": 0, "anchor": "AI triage", "paragraphIndex": 2 } ], "aeo": { "hasDirectAnswer": false, "proposedDirectAnswer": "AI is now used for diagnostics, triage and treatment planning in most large hospitals.", "questionHeadingCount": 0, "proposedQuestionHeadings": [], "faqs": [], "entities": [], "citedClaims": 2, "totalClaims": 5 }, "groupScores": { "onPage": 55, "keywords": 60, "links": 40, "aeo": 30 }, "overallScore": 48, "overall": "warn", "summary": "Add an SEO title, a meta description and one internal link.", "suggestionsAvailable": true }, "overall": "warn", "overallScore": 48, "summary": "Add an SEO title, a meta description and one internal link.", "credits": 4 } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/seo-audit" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"effort":"normal","language":"en-US"}' ``` ### SEO Audit History `GET /v1/stories/{storyId}/content/seo-audits` · operationId `listSeoAudits` · scopes: `stories:read` Every SEO audit that ran on the story, newest first, with the full report. Also available inline with `include=seoAudits` on the story endpoints. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | **Responses** - `200` History | Field | Type | Description | | --- | --- | --- | | `data` | SeoAudit[] | SEO audit records | | `data[].id` | string | Record id | | `data[].storyId` | string | Audited story | | `data[].overallScore` | number | 0 to 100 readiness score | | `data[].overall` | string | pass, warn or fail | | `data[].summary` | string | Short summary written in the report language | | `data[].groupScores` | object | Per group 0 to 100 scores: onPage, keywords, links, aeo | | `data[].checksRun` | string[] | Groups that were enabled | | `data[].report` | object | Full report: `storySnapshot`, `checks[]` (each may carry a `fix` you can apply with `POST /stories/{id}/content/seo-audit/apply-fix`), `keywords[]`, `linkCandidates[]`, `aeo` | | `data[].effort` | string | normal or high | | `data[].provider` | string | LLM provider used for suggestions | | `data[].model` | string | LLM model used | | `data[].credits` | number | Credits charged (0 when suggestions were skipped) | | `data[].createdAt` | string | ISO 8601 timestamp | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | | `meta.storyId` | string | The story | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f750", "storyId": "68b9b7c2f1a2b3c4d5e6f700", "overallScore": 48, "overall": "warn", "summary": "Add an SEO title, a meta description and one internal link.", "groupScores": { "onPage": 55, "keywords": 60, "links": 40, "aeo": 30 }, "checksRun": [ "onPage", "keywords", "links", "aeo" ], "report": { "storySnapshot": { "title": "AI Revolution in Healthcare", "seoTitle": "", "metaDescription": "", "slug": "ai-revolution-in-healthcare", "wordCount": 640, "locale": "en-US", "topicKey": "tech" }, "checks": [ { "id": "seoTitlePresent", "group": "onPage", "status": "fail", "impact": "high", "effort": "instant", "fix": { "kind": "setMetadataField", "target": "title", "after": "AI Revolution in Healthcare: What Changes in 2026" } }, { "id": "metaDescriptionLength", "group": "onPage", "status": "warn", "impact": "medium", "effort": "instant" } ], "keywords": [ { "term": "ai diagnostics", "primary": true, "bodyCount": 3, "present": [ "h1", "body" ], "suggestion": { "placement": "seoTitle", "before": "", "after": "AI diagnostics" } } ], "linkCandidates": [ { "storyId": "68b9b7c2f1a2b3c4d5e6f790", "title": "Hospitals adopt AI triage", "slug": "hospitals-adopt-ai-triage", "relevance": 81, "inboundLinks": 0, "anchor": "AI triage", "paragraphIndex": 2 } ], "aeo": { "hasDirectAnswer": false, "proposedDirectAnswer": "AI is now used for diagnostics, triage and treatment planning in most large hospitals.", "questionHeadingCount": 0, "proposedQuestionHeadings": [], "faqs": [], "entities": [], "citedClaims": 2, "totalClaims": 5 }, "groupScores": { "onPage": 55, "keywords": 60, "links": 40, "aeo": 30 }, "overallScore": 48, "overall": "warn", "summary": "Add an SEO title, a meta description and one internal link.", "suggestionsAvailable": true }, "effort": "normal", "provider": "anthropic", "model": "claude-sonnet-5", "credits": 4, "createdAt": "2026-08-16T09:30:00.000Z" } ], "meta": { "page": 0, "limit": 20, "hasMore": false, "storyId": "68b9b7c2f1a2b3c4d5e6f700" } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/seo-audits" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Apply SEO Fix `POST /v1/stories/{storyId}/content/seo-audit/apply-fix` · operationId `applySeoFix` · scopes: `ai:generate` + `stories:write` Apply one `fix` from an audit report. Fix kinds: setMetadataField, setSlug, setImageAlt, replaceText, insertParagraphTop, insertHeading, insertLink, setFaqs, setEntities. `direct` mode applies the fix without a model and answers 422 when the text changed since the audit; retry with `rewrite` mode to let the model apply it (costs credits). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `storyId` | string | yes | The story id | **Request body** (JSON): The fix and the mode | Name | Type | Required | Description | | --- | --- | --- | --- | | `fix` | object | yes | A `fix` object from `report.checks[]` or `report.keywords[].suggestion`: `{ kind, target?, before?, after, paragraphIndex?, payload? }` | | `mode` | string | no | direct (default) or rewrite | ```json { "fix": { "kind": "setMetadataField", "target": "title", "after": "AI in Healthcare 2026: Diagnostics, Costs and Results" }, "mode": "direct" } ``` **Responses** - `200` Result | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `storyId` | string | The story | | `applied` | boolean | Whether the story changed | | `method` | string | direct or rewrite: | | `field` | string | Field changed, e.g. metadata.title, slug, content | | `before` | string | Value before | | `after` | string | Value after | ```json { "success": true, "storyId": "68b9b7c2f1a2b3c4d5e6f700", "applied": true, "method": "direct", "field": "metadata.title", "before": "", "after": "AI in Healthcare 2026: Diagnostics, Costs and Results" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Story not found - `422` The story text drifted since the audit; retry with mode rewrite - `429` The organization's AI credits are exhausted **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/stories/68b9b7c2f1a2b3c4d5e6f700/content/seo-audit/apply-fix" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"fix":{"kind":"setMetadataField","target":"title","after":"AI in Healthcare 2026: Diagnostics, Costs and Results"},"mode":"direct"}' ``` ## Content Sources Articles, web pages, documents and images that stories are written from ### List Content Sources `GET /v1/content-sources` · operationId `listContentSources` · scopes: `content-sources:read` Page through the Topic Workspace's content sources: articles saved from feeds, web pages, uploaded documents and images. `usedIn` tells how many stories were written from each source. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `sourceType` | string (`rss`, `website`, `image`, `document`) | no | Only this kind of source | | `language` | string (`en-US`, `en-GB`, `de-DE`, `fr-FR`, `es-ES`, `es-MX`, `it-IT`, `pt-BR`, `pt-PT`, `nl-NL`, `pl-PL`, `sv-SE`, `da-DK`, `nb-NO`, `fi-FI`, `cs-CZ`, `ro-RO`, `hr-HR`, `tr-TR`, `ja-JP`) | no | Only sources in this language (full locale code) | | `flagged` | boolean | no | Only flagged (true) or unflagged (false) sources | | `processed` | boolean | no | Only sources that were (true) or were not (false) analysed | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | | `order` | string (`createdAt DESC`, `createdAt ASC`, `pubDate DESC`, `updatedAt DESC`, `title ASC`) | no | Sort order as `field ASC\|DESC` (default createdAt DESC). Allowed fields: createdAt, pubDate, updatedAt, title. | **Responses** - `200` A page of content sources | Field | Type | Description | | --- | --- | --- | | `data` | ContentSource[] | Content sources | | `data[].id` | string | Unique content source identifier | | `data[].title` | string | Title | | `data[].link` | string | Source URL (unique per Topic Workspace) | | `data[].sourceType` | string | rss, website, image or document | | `data[].summary` | string | Short summary or excerpt | | `data[].author` | string \| null | Author of the source | | `data[].thumbnail` | string \| null | Thumbnail image URL | | `data[].language` | string | Full locale code of the source text | | `data[].topicKey` | string | Topic Workspace the source belongs to | | `data[].pubDate` | string | ISO 8601 publication date | | `data[].scraped` | string \| null | When the page was fetched and parsed | | `data[].processed` | string \| null | When AI analysis (summary, embeddings) ran | | `data[].repurposed` | string \| null | When a story was last written from it | | `data[].flagged` | boolean | Flagged by a user or a workflow | | `data[].flagReason` | string \| null | Why it was flagged | | `data[].sourceMeta` | object | Source specific metadata: `feedId`, `feedName`, `feedUrl`, `feedIcon` for RSS articles, `documentType` or `imageType` for uploads | | `data[].usedIn` | number | How many stories reference this source | | `data[].createdAt` | string | ISO 8601 creation timestamp | | `data[].updatedAt` | string | ISO 8601 timestamp of the last update | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f760", "title": "Hospitals report 40% cost reduction with AI triage", "link": "https://techcrunch.com/2026/07/30/ai-healthcare/", "sourceType": "rss", "summary": "Pilot programmes across Europe report faster triage and lower costs.", "author": "Sarah Perez", "thumbnail": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "language": "en-US", "topicKey": "tech", "pubDate": "2026-07-30T06:00:00.000Z", "scraped": "2026-07-30T07:10:00.000Z", "processed": "2026-07-30T07:12:00.000Z", "repurposed": "2026-08-15T10:30:00.000Z", "flagged": false, "flagReason": null, "sourceMeta": { "feedId": "68b9b7c2f1a2b3c4d5e6f770", "feedName": "TechCrunch", "feedUrl": "https://techcrunch.com/feed/", "feedIcon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico" }, "usedIn": 1, "createdAt": "2026-07-30T07:00:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" } ], "meta": { "page": 0, "limit": 20, "hasMore": false } } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/content-sources?sourceType=rss&limit=20" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Create Content Source `POST /v1/content-sources` · operationId `createContentSource` · scopes: `content-sources:write` Register an article, page or document as a source in the Topic Workspace. Scrape it afterwards to fetch and parse the page, then repurpose it into a story. **Request body** (JSON): Content source data | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | yes | Title | | `link` | string | yes | Absolute URL of the article or page. Unique per Topic Workspace; a duplicate answers 409 with the existing id. | | `sourceType` | string | yes | rss, website, image or document | | `summary` | string | no | Summary or excerpt | | `content` | string | no | Full text when you already have it (otherwise use the scrape endpoint) | | `author` | string | no | Author | | `thumbnail` | string | no | Thumbnail URL | | `language` | string | no | Full locale code of the source text | | `pubDate` | string (ISO 8601) | no | Publication date (default now) | | `topicKey` | string | no | Topic Workspace to save the source in (default: the key's Topic Workspace) | ```json { "title": "Hospitals report 40% cost reduction with AI triage", "link": "https://techcrunch.com/2026/07/30/ai-healthcare/", "sourceType": "website", "summary": "Pilot programmes across Europe report faster triage and lower costs.", "language": "en-US" } ``` **Responses** - `201` Content source created | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data` | ContentSource | The created source | | `data.id` | string | Unique content source identifier | | `data.title` | string | Title | | `data.link` | string | Source URL (unique per Topic Workspace) | | `data.sourceType` | string | rss, website, image or document | | `data.summary` | string | Short summary or excerpt | | `data.author` | string \| null | Author of the source | | `data.thumbnail` | string \| null | Thumbnail image URL | | `data.language` | string | Full locale code of the source text | | `data.topicKey` | string | Topic Workspace the source belongs to | | `data.pubDate` | string | ISO 8601 publication date | | `data.scraped` | string \| null | When the page was fetched and parsed | | `data.processed` | string \| null | When AI analysis (summary, embeddings) ran | | `data.repurposed` | string \| null | When a story was last written from it | | `data.flagged` | boolean | Flagged by a user or a workflow | | `data.flagReason` | string \| null | Why it was flagged | | `data.sourceMeta` | object | Source specific metadata: `feedId`, `feedName`, `feedUrl`, `feedIcon` for RSS articles, `documentType` or `imageType` for uploads | | `data.usedIn` | number | How many stories reference this source | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string | ISO 8601 timestamp of the last update | | `message` | string | Success message | ```json { "success": true, "data": { "id": "68b9b7c2f1a2b3c4d5e6f760", "title": "Hospitals report 40% cost reduction with AI triage", "link": "https://techcrunch.com/2026/07/30/ai-healthcare/", "sourceType": "website", "summary": "Pilot programmes across Europe report faster triage and lower costs.", "author": "Sarah Perez", "thumbnail": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "language": "en-US", "topicKey": "tech", "pubDate": "2026-07-30T06:00:00.000Z", "scraped": null, "processed": null, "repurposed": null, "flagged": false, "flagReason": null, "sourceMeta": {}, "usedIn": 0, "createdAt": "2026-07-30T07:00:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" }, "message": "Content source created successfully" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `409` Conflict - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/content-sources" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"title":"Hospitals report 40% cost reduction with AI triage","link":"https://techcrunch.com/2026/07/30/ai-healthcare/","sourceType":"website","summary":"Pilot programmes across Europe report faster triage and lower costs.","language":"en-US"}' ``` ### Get Content Source `GET /v1/content-sources/{id}` · operationId `getContentSource` · scopes: `content-sources:read` One content source with its scraped text, images, catalogue feed and the stories written from it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The content source id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `full` | boolean | no | Also return the raw HTML (`rawSource`) of scraped pages | **Responses** - `200` The content source | Field | Type | Description | | --- | --- | --- | | `data` | ContentSource | The content source | | `data.id` | string | Unique content source identifier | | `data.title` | string | Title | | `data.link` | string | Source URL (unique per Topic Workspace) | | `data.sourceType` | string | rss, website, image or document | | `data.summary` | string | Short summary or excerpt | | `data.author` | string \| null | Author of the source | | `data.thumbnail` | string \| null | Thumbnail image URL | | `data.language` | string | Full locale code of the source text | | `data.topicKey` | string | Topic Workspace the source belongs to | | `data.pubDate` | string | ISO 8601 publication date | | `data.scraped` | string \| null | When the page was fetched and parsed | | `data.processed` | string \| null | When AI analysis (summary, embeddings) ran | | `data.repurposed` | string \| null | When a story was last written from it | | `data.flagged` | boolean | Flagged by a user or a workflow | | `data.flagReason` | string \| null | Why it was flagged | | `data.sourceMeta` | object | Source specific metadata: `feedId`, `feedName`, `feedUrl`, `feedIcon` for RSS articles, `documentType` or `imageType` for uploads | | `data.usedIn` | number | How many stories reference this source | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string | ISO 8601 timestamp of the last update | | `data.content` | string \| null | Full text when available (RSS body or document text) | | `data.parsedSource` | object \| null | Scrape result: `title`, `contentHTML`, `contentText`, `mainImage`, `excerpt`, `wordCount` | | `data.images` | Image[] | Images attached to the source as public Image objects | | `data.feed` | Feed \| null | The catalogue feed for RSS articles | | `data.stories` | object[] | Stories written from this source: `{ id, title, createdAt }` | | `data.images[].id` | string | Unique image identifier | | `data.images[].url` | string | Public URL of the image file | | `data.images[].s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data.images[].width` | number | Width in pixels | | `data.images[].height` | number | Height in pixels | | `data.images[].format` | string | File format (PNG, JPEG, WEBP) | | `data.images[].contentType` | string | MIME type of the image | | `data.images[].fileSize` | number \| null | File size in bytes | | `data.images[].imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data.images[].origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data.images[].aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data.images[].generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data.images[].sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data.images[].parentId` | string \| null | Id of the image this one was cropped or resized from | | `data.images[].originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data.images[].title` | string \| null | Image title | | `data.images[].description` | string \| null | Image description | | `data.images[].altText` | string \| null | Alt text | | `data.images[].caption` | string \| null | Caption | | `data.images[].author` | string \| null | Author or photographer | | `data.images[].source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data.images[].license` | string \| null | License (stock images) | | `data.images[].creditLine` | string \| null | Credit line to display (stock images) | | `data.images[].tags` | string[] | Tags associated with the image | | `data.images[].createdAt` | string | ISO 8601 creation timestamp | | `data.images[].updatedAt` | string \| null | ISO 8601 timestamp of the last update | ```json { "data": { "id": "68b9b7c2f1a2b3c4d5e6f760", "title": "Hospitals report 40% cost reduction with AI triage", "link": "https://techcrunch.com/2026/07/30/ai-healthcare/", "sourceType": "rss", "summary": "Pilot programmes across Europe report faster triage and lower costs.", "author": "Sarah Perez", "thumbnail": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "language": "en-US", "topicKey": "tech", "pubDate": "2026-07-30T06:00:00.000Z", "scraped": "2026-07-30T07:10:00.000Z", "processed": "2026-07-30T07:12:00.000Z", "repurposed": "2026-08-15T10:30:00.000Z", "flagged": false, "flagReason": null, "sourceMeta": { "feedId": "68b9b7c2f1a2b3c4d5e6f770", "feedName": "TechCrunch", "feedUrl": "https://techcrunch.com/feed/", "feedIcon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico" }, "usedIn": 1, "createdAt": "2026-07-30T07:00:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z", "content": null, "parsedSource": { "title": "Hospitals report 40% cost reduction with AI triage", "contentHTML": "

Pilot programmes across Europe...

", "contentText": "Pilot programmes across Europe...", "mainImage": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "excerpt": "Pilot programmes across Europe report faster triage and lower costs.", "wordCount": 1250 }, "images": [ { "id": "68b9b7c2f1a2b3c4d5e6f701", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "originalUrl": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "width": 1024, "height": 683, "format": "JPEG", "contentType": "image/jpeg", "fileSize": 214532, "imageType": "main", "origin": "source", "aiGenerated": false, "generation": null, "sourceType": "story", "parentId": null, "title": "AI Revolution in Healthcare", "description": null, "altText": null, "caption": null, "author": null, "source": "https://techcrunch.com/2026/07/30/ai-healthcare/", "license": null, "creditLine": null, "tags": [], "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" } ], "feed": { "id": "68b9b7c2f1a2b3c4d5e6f770", "name": "TechCrunch", "url": "https://techcrunch.com/feed/", "favicon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico" }, "stories": [ { "id": "68b9b7c2f1a2b3c4d5e6f700", "title": "AI Revolution in Healthcare", "createdAt": "2026-08-15T10:30:00.000Z" } ] } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Content source not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/content-sources/68b9b7c2f1a2b3c4d5e6f760" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Update Content Source `PATCH /v1/content-sources/{id}` · operationId `updateContentSource` · scopes: `content-sources:write` Update editable fields (title, summary, content, author, thumbnail, language, pubDate, flagged, flagReason) and return the fresh record. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The content source id | **Request body** (JSON): Fields to update (at least one) | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | Title | | `summary` | string | no | Summary or excerpt | | `content` | string | no | Full text when you already have it (otherwise use the scrape endpoint) | | `author` | string | no | Author | | `thumbnail` | string | no | Thumbnail URL | | `language` | string | no | Full locale code of the source text | | `pubDate` | string (ISO 8601) | no | Publication date (default now) | | `flagged` | boolean | no | Flag the source so workflows skip it | | `flagReason` | string | no | Why it is flagged | ```json { "flagged": true, "flagReason": "Duplicate of another source" } ``` **Responses** - `200` Content source updated | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data` | ContentSource | The updated source | | `data.id` | string | Unique content source identifier | | `data.title` | string | Title | | `data.link` | string | Source URL (unique per Topic Workspace) | | `data.sourceType` | string | rss, website, image or document | | `data.summary` | string | Short summary or excerpt | | `data.author` | string \| null | Author of the source | | `data.thumbnail` | string \| null | Thumbnail image URL | | `data.language` | string | Full locale code of the source text | | `data.topicKey` | string | Topic Workspace the source belongs to | | `data.pubDate` | string | ISO 8601 publication date | | `data.scraped` | string \| null | When the page was fetched and parsed | | `data.processed` | string \| null | When AI analysis (summary, embeddings) ran | | `data.repurposed` | string \| null | When a story was last written from it | | `data.flagged` | boolean | Flagged by a user or a workflow | | `data.flagReason` | string \| null | Why it was flagged | | `data.sourceMeta` | object | Source specific metadata: `feedId`, `feedName`, `feedUrl`, `feedIcon` for RSS articles, `documentType` or `imageType` for uploads | | `data.usedIn` | number | How many stories reference this source | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string | ISO 8601 timestamp of the last update | | `data.content` | string \| null | Full text when available (RSS body or document text) | | `data.parsedSource` | object \| null | Scrape result: `title`, `contentHTML`, `contentText`, `mainImage`, `excerpt`, `wordCount` | | `data.images` | Image[] | Images attached to the source as public Image objects | | `data.feed` | Feed \| null | The catalogue feed for RSS articles | | `data.stories` | object[] | Stories written from this source: `{ id, title, createdAt }` | | `data.images[].id` | string | Unique image identifier | | `data.images[].url` | string | Public URL of the image file | | `data.images[].s3Url` | string | Deprecated alias of `url` with the same value; use `url` | | `data.images[].width` | number | Width in pixels | | `data.images[].height` | number | Height in pixels | | `data.images[].format` | string | File format (PNG, JPEG, WEBP) | | `data.images[].contentType` | string | MIME type of the image | | `data.images[].fileSize` | number \| null | File size in bytes | | `data.images[].imageType` | string | Role on the story: main, thumbnail, gallery, content, other | | `data.images[].origin` | string | Normalized provenance: `generated` (made by News Factory with an AI image model), `stock` (licensed stock provider), `source` (taken from the original article, usually not yours to republish), `upload` (uploaded or imported by URL) | | `data.images[].aiGenerated` | boolean | Authoritative flag: true only when News Factory produced the image with an AI model, including crops and thumbnails derived from one. Use this (not `sourceType`) to pick images you can publish as your own. | | `data.images[].generation` | object \| null | Present when `aiGenerated` is true: `{ provider, model, prompt }` (each may be null for older images) | | `data.images[].sourceType` | string | Raw storage source (generated, stock, story, content-source, scraped, user-upload, url). Kept for compatibility; not a reliable AI indicator | | `data.images[].parentId` | string \| null | Id of the image this one was cropped or resized from | | `data.images[].originalUrl` | string \| null | Where the file was fetched from (article images, URL imports) | | `data.images[].title` | string \| null | Image title | | `data.images[].description` | string \| null | Image description | | `data.images[].altText` | string \| null | Alt text | | `data.images[].caption` | string \| null | Caption | | `data.images[].author` | string \| null | Author or photographer | | `data.images[].source` | string \| null | Attribution source: article URL, stock provider id, or AI provider id | | `data.images[].license` | string \| null | License (stock images) | | `data.images[].creditLine` | string \| null | Credit line to display (stock images) | | `data.images[].tags` | string[] | Tags associated with the image | | `data.images[].createdAt` | string | ISO 8601 creation timestamp | | `data.images[].updatedAt` | string \| null | ISO 8601 timestamp of the last update | | `message` | string | Success message | ```json { "success": true, "data": { "id": "68b9b7c2f1a2b3c4d5e6f760", "title": "Hospitals report 40% cost reduction with AI triage", "link": "https://techcrunch.com/2026/07/30/ai-healthcare/", "sourceType": "rss", "summary": "Pilot programmes across Europe report faster triage and lower costs.", "author": "Sarah Perez", "thumbnail": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "language": "en-US", "topicKey": "tech", "pubDate": "2026-07-30T06:00:00.000Z", "scraped": "2026-07-30T07:10:00.000Z", "processed": "2026-07-30T07:12:00.000Z", "repurposed": "2026-08-15T10:30:00.000Z", "flagged": true, "flagReason": "Duplicate of another source", "sourceMeta": { "feedId": "68b9b7c2f1a2b3c4d5e6f770", "feedName": "TechCrunch", "feedUrl": "https://techcrunch.com/feed/", "feedIcon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico" }, "usedIn": 1, "createdAt": "2026-07-30T07:00:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z", "content": null, "parsedSource": { "title": "Hospitals report 40% cost reduction with AI triage", "contentHTML": "

Pilot programmes across Europe...

", "contentText": "Pilot programmes across Europe...", "mainImage": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "excerpt": "Pilot programmes across Europe report faster triage and lower costs.", "wordCount": 1250 }, "images": [ { "id": "68b9b7c2f1a2b3c4d5e6f701", "url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "s3Url": "https://cdn.news-factory.app/stories/68b9b7c2f1a2b3c4d5e6f700/gettyimages-1445867611.jpg", "originalUrl": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "width": 1024, "height": 683, "format": "JPEG", "contentType": "image/jpeg", "fileSize": 214532, "imageType": "main", "origin": "source", "aiGenerated": false, "generation": null, "sourceType": "story", "parentId": null, "title": "AI Revolution in Healthcare", "description": null, "altText": null, "caption": null, "author": null, "source": "https://techcrunch.com/2026/07/30/ai-healthcare/", "license": null, "creditLine": null, "tags": [], "createdAt": "2026-08-15T10:30:00.000Z", "updatedAt": "2026-08-15T10:30:00.000Z" } ], "feed": { "id": "68b9b7c2f1a2b3c4d5e6f770", "name": "TechCrunch", "url": "https://techcrunch.com/feed/", "favicon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico" }, "stories": [ { "id": "68b9b7c2f1a2b3c4d5e6f700", "title": "AI Revolution in Healthcare", "createdAt": "2026-08-15T10:30:00.000Z" } ] }, "message": "Content source updated successfully" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Content source not found **Example** ```bash curl -X PATCH "https://client-api.news-factory.app/v1/content-sources/68b9b7c2f1a2b3c4d5e6f760" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"flagged":true,"flagReason":"Duplicate of another source"}' ``` ### Delete Content Source `DELETE /v1/content-sources/{id}` · operationId `deleteContentSource` · scopes: `content-sources:write` Delete a content source. Its images are removed from storage when no story uses them. Stories written from the source are kept. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The content source id | **Responses** - `200` Content source deleted | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `message` | string | Success message | ```json { "success": true, "message": "Content source deleted successfully" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Content source not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X DELETE "https://client-api.news-factory.app/v1/content-sources/68b9b7c2f1a2b3c4d5e6f760" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Scrape Content Source `POST /v1/content-sources/{id}/scrape` · operationId `scrapeContentSource` · scopes: `content-sources:write` Fetch the source page and extract its main text, title and image into `parsedSource`. Sources that were already scraped are left alone unless `force` is true. Read the result with `GET /content-sources/{id}`. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The content source id | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `force` | boolean | no | Fetch again even if the page was scraped before | ```json { "force": false } ``` **Responses** - `200` Scrape result | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.id` | string | The content source | | `data.scraped` | boolean | Whether a fetch happened in this call | | `data.scrapedAt` | string \| null | When the page was fetched | | `data.parsedSource` | object \| null | `{ title, contentHTML, contentText, mainImage, excerpt, wordCount }` when a fetch happened | | `message` | string | What happened | ```json { "success": true, "data": { "id": "68b9b7c2f1a2b3c4d5e6f760", "scraped": true, "scrapedAt": "2026-07-30T07:10:00.000Z", "parsedSource": { "title": "Hospitals report 40% cost reduction with AI triage", "contentHTML": "

Pilot programmes across Europe...

", "contentText": "Pilot programmes across Europe...", "mainImage": "https://techcrunch.com/wp-content/uploads/gettyimages-1445867611.jpg", "excerpt": "Pilot programmes across Europe report faster triage and lower costs.", "wordCount": 1250 } }, "message": "Source scraped" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Content source not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/content-sources/68b9b7c2f1a2b3c4d5e6f760/scrape" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"force":false}' ``` ### Repurpose into a Story `POST /v1/content-sources/{id}/repurpose` · operationId `repurposeContentSource` · scopes: `ai:generate` + `content-sources:read` + `stories:write` Write a new story from the content source with the Topic Workspace's story settings (tone, length, structure). Scrape the source first so the model has the full text. Costs AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The content source id | **Request body** (JSON): Options | Name | Type | Required | Description | | --- | --- | --- | --- | | `languageCode` | string | no | Language of the new story as a full locale code (default: the Topic Workspace's default language) | | `extraInstructions` | string | no | Extra editorial instructions for this story | | `authorId` | string | no | Author to attribute the story to (also applies the author's writing style) | | `temperature` | number | no | Model temperature 0 to 1 | ```json { "languageCode": "en-US" } ``` **Responses** - `200` Story written | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.contentSourceId` | string | The source | | `data.storyId` | string | Id of the new story (load it with `GET /stories/{id}`) | | `data.storyTitle` | string | Title of the new story | | `data.contentSourceIds` | string[] | Sources the story was written from | | `data.authorId` | string \| null | Attributed author | | `message` | string | Success message | ```json { "success": true, "data": { "contentSourceId": "68b9b7c2f1a2b3c4d5e6f760", "storyId": "68b9b7c2f1a2b3c4d5e6f700", "storyTitle": "AI Revolution in Healthcare", "contentSourceIds": [ "68b9b7c2f1a2b3c4d5e6f760" ], "authorId": null }, "message": "Content source repurposed into a story" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Content source not found - `429` The organization's AI credits are exhausted - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/content-sources/68b9b7c2f1a2b3c4d5e6f760/repurpose" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"languageCode":"en-US"}' ``` ## RSS Feeds Subscribe to catalogue feeds and read their articles with the topic's evaluation ### List Subscribed Feeds `GET /v1/feeds` · operationId `listFeeds` · scopes: `feeds:read` The feeds the Topic Workspace follows. Articles from these feeds are what the News Hub and workflows evaluate. Browse other feeds with the catalogue endpoint and subscribe with `POST /feeds`. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | number | no | Page size (default 50, max 100) | | `page` | number | no | Zero-based page number (default 0) | **Responses** - `200` The subscribed feeds | Field | Type | Description | | --- | --- | --- | | `data` | Feed[] | Feeds (all with `subscribed: true`) | | `data[].id` | string | Unique feed identifier | | `data[].url` | string | RSS or Atom feed URL | | `data[].name` | string | Feed name | | `data[].website` | string \| null | Publisher website | | `data[].favicon` | string \| null | Favicon URL | | `data[].language` | string \| null | Language of the feed as published by the catalogue (bare ISO code, the catalogue does not carry regions) | | `data[].country` | string \| null | ISO country code | | `data[].category` | string \| null | news, technology, sports, business, science, ... | | `data[].contentType` | string \| null | news, wire, magazine, trade, local or blog | | `data[].trustTier` | number \| null | Editorial trust tier (1 is highest) | | `data[].qualityScore` | number \| null | Catalogue quality score | | `data[].articlesPerDay` | number \| null | Average publishing rate | | `data[].enabled` | boolean | Whether the platform fetches this feed | | `data[].checkFrequency` | number | Seconds between fetches | | `data[].lastFetchedAt` | string \| null | Last successful fetch | | `data[].nextFetchDue` | string \| null | Next scheduled fetch | | `data[].subscribed` | boolean | Whether the current Topic Workspace follows this feed | | `data[].createdAt` | string | ISO 8601 creation timestamp | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | | `meta.total` | number | Number of subscribed feeds | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f770", "url": "https://techcrunch.com/feed/", "name": "TechCrunch", "website": "https://techcrunch.com", "favicon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico", "language": "en", "country": "US", "category": "technology", "contentType": "news", "trustTier": 2, "qualityScore": 82, "articlesPerDay": 38, "enabled": true, "checkFrequency": 3600, "lastFetchedAt": "2026-09-05T10:00:00.000Z", "nextFetchDue": "2026-09-05T11:00:00.000Z", "subscribed": true, "createdAt": "2026-01-15T09:00:00.000Z" } ], "meta": { "page": 0, "limit": 50, "hasMore": false, "total": 1 } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/feeds" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Subscribe to Feed `POST /v1/feeds` · operationId `subscribeFeed` · scopes: `feeds:write` Follow a catalogue feed for the Topic Workspace, by `feedId` or by its feed `url`. Feeds outside the catalogue answer 404: the catalogue is curated, ask support to add a source. Subscribing twice is harmless. **Request body** (JSON): feedId or url | Name | Type | Required | Description | | --- | --- | --- | --- | | `feedId` | string | no | Catalogue feed id | | `url` | string | no | Feed URL exactly as listed in the catalogue | ```json { "feedId": "68b9b7c2f1a2b3c4d5e6f770" } ``` **Responses** - `200` Subscribed | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.feed` | Feed | The feed | | `data.feed.id` | string | Unique feed identifier | | `data.feed.url` | string | RSS or Atom feed URL | | `data.feed.name` | string | Feed name | | `data.feed.website` | string \| null | Publisher website | | `data.feed.favicon` | string \| null | Favicon URL | | `data.feed.language` | string \| null | Language of the feed as published by the catalogue (bare ISO code, the catalogue does not carry regions) | | `data.feed.country` | string \| null | ISO country code | | `data.feed.category` | string \| null | news, technology, sports, business, science, ... | | `data.feed.contentType` | string \| null | news, wire, magazine, trade, local or blog | | `data.feed.trustTier` | number \| null | Editorial trust tier (1 is highest) | | `data.feed.qualityScore` | number \| null | Catalogue quality score | | `data.feed.articlesPerDay` | number \| null | Average publishing rate | | `data.feed.enabled` | boolean | Whether the platform fetches this feed | | `data.feed.checkFrequency` | number | Seconds between fetches | | `data.feed.lastFetchedAt` | string \| null | Last successful fetch | | `data.feed.nextFetchDue` | string \| null | Next scheduled fetch | | `data.feed.subscribed` | boolean | Whether the current Topic Workspace follows this feed | | `data.feed.createdAt` | string | ISO 8601 creation timestamp | | `data.topicKey` | string | Topic Workspace that now follows the feed | | `data.followedFeeds` | string[] | All feed ids the Topic Workspace follows | | `message` | string | Success message | ```json { "success": true, "data": { "feed": { "id": "68b9b7c2f1a2b3c4d5e6f770", "url": "https://techcrunch.com/feed/", "name": "TechCrunch", "website": "https://techcrunch.com", "favicon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico", "language": "en", "country": "US", "category": "technology", "contentType": "news", "trustTier": 2, "qualityScore": 82, "articlesPerDay": 38, "enabled": true, "checkFrequency": 3600, "lastFetchedAt": "2026-09-05T10:00:00.000Z", "nextFetchDue": "2026-09-05T11:00:00.000Z", "subscribed": true, "createdAt": "2026-01-15T09:00:00.000Z" }, "topicKey": "tech", "followedFeeds": [ "68b9b7c2f1a2b3c4d5e6f770" ] }, "message": "Successfully followed feed" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Feed not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/feeds" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{"feedId":"68b9b7c2f1a2b3c4d5e6f770"}' ``` ### Search Feed Catalog `GET /v1/feeds/catalog` · operationId `searchFeedCatalog` · scopes: `feeds:read` Search the curated catalogue of feeds by name, URL, language, country or category. Use a result's `id` with `POST /feeds` to subscribe. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `search` | string | no | Matches the feed name, URL or website (case insensitive) | | `language` | string | no | Catalogue language code (bare ISO code such as en, de, pt) | | `country` | string | no | ISO country code | | `category` | string | no | news, technology, business, science, sports, ... | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | | `order` | string (`name ASC`, `qualityScore DESC`, `articlesPerDay DESC`, `trustTier ASC`) | no | Sort order as `field ASC\|DESC` (default name ASC). Allowed fields: name, qualityScore, articlesPerDay, trustTier. | **Responses** - `200` Matching feeds | Field | Type | Description | | --- | --- | --- | | `data` | Feed[] | Catalogue feeds (`subscribed` is not included here) | | `data[].id` | string | Unique feed identifier | | `data[].url` | string | RSS or Atom feed URL | | `data[].name` | string | Feed name | | `data[].website` | string \| null | Publisher website | | `data[].favicon` | string \| null | Favicon URL | | `data[].language` | string \| null | Language of the feed as published by the catalogue (bare ISO code, the catalogue does not carry regions) | | `data[].country` | string \| null | ISO country code | | `data[].category` | string \| null | news, technology, sports, business, science, ... | | `data[].contentType` | string \| null | news, wire, magazine, trade, local or blog | | `data[].trustTier` | number \| null | Editorial trust tier (1 is highest) | | `data[].qualityScore` | number \| null | Catalogue quality score | | `data[].articlesPerDay` | number \| null | Average publishing rate | | `data[].enabled` | boolean | Whether the platform fetches this feed | | `data[].checkFrequency` | number | Seconds between fetches | | `data[].lastFetchedAt` | string \| null | Last successful fetch | | `data[].nextFetchDue` | string \| null | Next scheduled fetch | | `data[].createdAt` | string | ISO 8601 creation timestamp | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f770", "url": "https://techcrunch.com/feed/", "name": "TechCrunch", "website": "https://techcrunch.com", "favicon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico", "language": "en", "country": "US", "category": "technology", "contentType": "news", "trustTier": 2, "qualityScore": 82, "articlesPerDay": 38, "enabled": true, "checkFrequency": 3600, "lastFetchedAt": "2026-09-05T10:00:00.000Z", "nextFetchDue": "2026-09-05T11:00:00.000Z", "createdAt": "2026-01-15T09:00:00.000Z" } ], "meta": { "page": 0, "limit": 20, "hasMore": false } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/feeds/catalog?search=techcrunch&language=en&limit=10" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Get Feed `GET /v1/feeds/{id}` · operationId `getFeed` · scopes: `feeds:read` One catalogue feed with its fetch state and whether the Topic Workspace follows it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The feed id | **Responses** - `200` The feed | Field | Type | Description | | --- | --- | --- | | `data` | Feed | The feed | | `data.id` | string | Unique feed identifier | | `data.url` | string | RSS or Atom feed URL | | `data.name` | string | Feed name | | `data.website` | string \| null | Publisher website | | `data.favicon` | string \| null | Favicon URL | | `data.language` | string \| null | Language of the feed as published by the catalogue (bare ISO code, the catalogue does not carry regions) | | `data.country` | string \| null | ISO country code | | `data.category` | string \| null | news, technology, sports, business, science, ... | | `data.contentType` | string \| null | news, wire, magazine, trade, local or blog | | `data.trustTier` | number \| null | Editorial trust tier (1 is highest) | | `data.qualityScore` | number \| null | Catalogue quality score | | `data.articlesPerDay` | number \| null | Average publishing rate | | `data.enabled` | boolean | Whether the platform fetches this feed | | `data.checkFrequency` | number | Seconds between fetches | | `data.lastFetchedAt` | string \| null | Last successful fetch | | `data.nextFetchDue` | string \| null | Next scheduled fetch | | `data.subscribed` | boolean | Whether the current Topic Workspace follows this feed | | `data.createdAt` | string | ISO 8601 creation timestamp | ```json { "data": { "id": "68b9b7c2f1a2b3c4d5e6f770", "url": "https://techcrunch.com/feed/", "name": "TechCrunch", "website": "https://techcrunch.com", "favicon": "https://cdn.news-factory.app/feeds/techcrunch/favicon.ico", "language": "en", "country": "US", "category": "technology", "contentType": "news", "trustTier": 2, "qualityScore": 82, "articlesPerDay": 38, "enabled": true, "checkFrequency": 3600, "lastFetchedAt": "2026-09-05T10:00:00.000Z", "nextFetchDue": "2026-09-05T11:00:00.000Z", "subscribed": true, "createdAt": "2026-01-15T09:00:00.000Z" } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Feed not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/feeds/68b9b7c2f1a2b3c4d5e6f770" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Unsubscribe from Feed `DELETE /v1/feeds/{id}` · operationId `unsubscribeFeed` · scopes: `feeds:write` Stop following a feed for the Topic Workspace. Articles already saved as content sources are kept. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The feed id | **Responses** - `200` Unsubscribed | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `message` | string | Success message | ```json { "success": true, "message": "Unsubscribed from feed" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` The Topic Workspace does not follow this feed - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X DELETE "https://client-api.news-factory.app/v1/feeds/68b9b7c2f1a2b3c4d5e6f770" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Fetch Feed Now `POST /v1/feeds/{id}/fetch` · operationId `fetchFeedArticles` · scopes: `feeds:write` Fetch the feed immediately instead of waiting for the scheduler, and store new articles in the catalogue. Unchanged feeds (HTTP 304) answer with `modified: false`. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The feed id | **Responses** - `200` Fetch result | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.feedId` | string | The feed | | `data.modified` | boolean | False when the publisher answered 304 Not Modified | | `data.statusCode` | number \| null | HTTP status from the publisher | | `data.feedTitle` | string \| null | Title from the feed | | `data.itemCount` | number | Items in the feed | | `data.items` | object[] | Parsed items: `{ title, link, pubDate, description, author, thumbnail }` | | `data.persistence` | object \| null | How many items were created or updated in the catalogue | | `data.linkedToContentSources` | number | Items matched to existing content sources | | `message` | string | What happened | ```json { "success": true, "data": { "feedId": "68b9b7c2f1a2b3c4d5e6f770", "modified": true, "statusCode": 200, "feedTitle": "TechCrunch", "itemCount": 20, "items": [ { "title": "OpenAI announces new medical reasoning model", "link": "https://techcrunch.com/2026/09/04/openai-medical-model/", "pubDate": "2026-09-04T15:00:00.000Z", "description": "The company says the model is tuned for clinical documentation.", "author": "Kyle Wiggers", "thumbnail": "https://techcrunch.com/wp-content/uploads/openai-medical.jpg" } ], "persistence": { "created": 3, "updated": 17 }, "linkedToContentSources": 0 }, "message": "Feed fetched" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Feed not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/feeds/68b9b7c2f1a2b3c4d5e6f770/fetch" \ -H "Authorization: Bearer nfk_xxx..." ``` ### List Articles `GET /v1/feeds/articles` · operationId `listFeedArticles` · scopes: `feeds:read` Articles from the Topic Workspace's subscribed feeds together with their topic evaluation. By default returns evaluated articles (newest evaluation first), filterable by `minConfidence`; `feedId` pages through one feed's articles evaluated or not; `unevaluated=true` returns what the Topic Workspace has not looked at yet. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `feedId` | string | no | Only this feed's articles (evaluated and unevaluated) | | `minConfidence` | number | no | Only articles evaluated at this topic match or higher (0 to 100) | | `evaluatedBy` | string (`agent`, `human`) | no | Only evaluations by the agent or by a person | | `unevaluated` | boolean | no | Only articles without an evaluation for this Topic Workspace | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | | `order` | string (`createdAt DESC`, `pubDate DESC`, `matchConfidence DESC`) | no | Sort order as `field ASC\|DESC` (default createdAt DESC). Allowed fields: createdAt, pubDate, matchConfidence. | **Responses** - `200` A page of articles | Field | Type | Description | | --- | --- | --- | | `data` | Article[] | Articles | | `data[].id` | string | Unique article identifier | | `data[].title` | string | Article title | | `data[].url` | string | Article URL | | `data[].description` | string \| null | Description or summary from the feed | | `data[].author` | string \| null | Author | | `data[].thumbnail` | string \| null | Image URL from the feed | | `data[].pubDate` | string | ISO 8601 publication date | | `data[].language` | string \| null | Language of the article as published by the feed | | `data[].feedId` | string | Feed the article came from | | `data[].matchConfidence` | number \| null | Topic match from 0 to 100. `null` means not evaluated yet for this Topic Workspace; `0` means evaluated as no match | | `data[].evaluatedBy` | string \| null | agent, human or null | | `data[].evaluatedAt` | string \| null | When the evaluation was recorded | | `data[].createdAt` | string \| null | When the article entered the catalogue | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | | `meta.unevaluated` | boolean | Whether the unevaluated view was returned | | `meta.feedId` | string | Feed filter applied (feed view only) | | `meta.total` | number | Total unevaluated articles (unevaluated view only) | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f780", "title": "OpenAI announces new medical reasoning model", "url": "https://techcrunch.com/2026/09/04/openai-medical-model/", "description": "The company says the model is tuned for clinical documentation.", "author": "Kyle Wiggers", "thumbnail": "https://techcrunch.com/wp-content/uploads/openai-medical.jpg", "pubDate": "2026-09-04T15:00:00.000Z", "language": "en", "feedId": "68b9b7c2f1a2b3c4d5e6f770", "matchConfidence": 88, "evaluatedBy": "agent", "evaluatedAt": "2026-09-04T15:30:00.000Z", "createdAt": "2026-09-04T15:05:00.000Z" } ], "meta": { "page": 0, "limit": 20, "hasMore": false, "unevaluated": false } } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/feeds/articles?minConfidence=50&limit=20" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Get Article `GET /v1/feeds/articles/{id}` · operationId `getArticleDetails` · scopes: `feeds:read` One article with its evaluation for the Topic Workspace. Articles the Topic Workspace has not evaluated yet are only found when you also pass their `feedId`. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The article id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `feedId` | string | no | The article's feed, needed for unevaluated articles | **Responses** - `200` The article | Field | Type | Description | | --- | --- | --- | | `data` | Article | The article | | `data.id` | string | Unique article identifier | | `data.title` | string | Article title | | `data.url` | string | Article URL | | `data.description` | string \| null | Description or summary from the feed | | `data.author` | string \| null | Author | | `data.thumbnail` | string \| null | Image URL from the feed | | `data.pubDate` | string | ISO 8601 publication date | | `data.language` | string \| null | Language of the article as published by the feed | | `data.feedId` | string | Feed the article came from | | `data.matchConfidence` | number \| null | Topic match from 0 to 100. `null` means not evaluated yet for this Topic Workspace; `0` means evaluated as no match | | `data.evaluatedBy` | string \| null | agent, human or null | | `data.evaluatedAt` | string \| null | When the evaluation was recorded | | `data.createdAt` | string \| null | When the article entered the catalogue | ```json { "data": { "id": "68b9b7c2f1a2b3c4d5e6f780", "title": "OpenAI announces new medical reasoning model", "url": "https://techcrunch.com/2026/09/04/openai-medical-model/", "description": "The company says the model is tuned for clinical documentation.", "author": "Kyle Wiggers", "thumbnail": "https://techcrunch.com/wp-content/uploads/openai-medical.jpg", "pubDate": "2026-09-04T15:00:00.000Z", "language": "en", "feedId": "68b9b7c2f1a2b3c4d5e6f770", "matchConfidence": 88, "evaluatedBy": "agent", "evaluatedAt": "2026-09-04T15:30:00.000Z", "createdAt": "2026-09-04T15:05:00.000Z" } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Not found, or not evaluated yet and no feedId given **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/feeds/articles/68b9b7c2f1a2b3c4d5e6f780" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Scheduler Status `GET /v1/feeds/scheduler/status` · operationId `schedulerStatus` · scopes: `feeds:read` State of the platform's feed fetch scheduler: whether it runs, how often, and when the next global fetch is due. **Responses** - `200` Scheduler state | Field | Type | Description | | --- | --- | --- | | `data.active` | boolean | Whether the scheduler job is registered | | `data.nextRun` | string \| null | ISO 8601 time of the next global fetch | | `data.intervalMinutes` | number \| null | Minutes between fetch rounds | | `data.jobCount` | number | Scheduled jobs in the queue | | `data.timestamp` | string | When this status was read | ```json { "data": { "active": true, "nextRun": "2026-09-05T13:00:00.000Z", "intervalMinutes": 30, "jobCount": 2, "timestamp": "2026-09-05T12:41:03.000Z" } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/feeds/scheduler/status" \ -H "Authorization: Bearer nfk_xxx..." ``` ## Workflows Run the automation workflows designed in the app and follow their runs step by step ### List Workflows `GET /v1/workflows` · operationId `listWorkflows` · scopes: `workflows:read` The Topic Workspace's workflows with their node list. Workflows are designed in the app; the API lets you run them and follow their runs. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isActive` | boolean | no | Only active (true) or inactive (false) workflows | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | | `order` | string (`createdAt DESC`, `updatedAt DESC`, `name ASC`) | no | Sort order as `field ASC\|DESC` (default createdAt DESC). Allowed fields: createdAt, updatedAt, name. | **Responses** - `200` A page of workflows | Field | Type | Description | | --- | --- | --- | | `data` | Workflow[] | Workflows | | `data[].id` | string | Unique workflow identifier | | `data[].name` | string | Workflow name | | `data[].description` | string \| null | Description | | `data[].isActive` | boolean | Inactive workflows cannot be run | | `data[].isDefault` | boolean | The Topic Workspace's default workflow | | `data[].topicKey` | string | Topic Workspace the workflow belongs to | | `data[].feedIds` | string[] \| undefined | The workflow's own feed list, up to 10 feed ids. Absent when the workflow reads the Topic Workspace's followed feeds | | `data[].triggerConfig` | object | `{ type: manual \| scheduled, schedule?: { frequency, days, times, timezone } }` | | `data[].scheduleState` | object \| null | Server managed schedule state: `nextRunAt`, `lastTriggeredAt`, `lastRunId`, `lastSkipReason` | | `data[].createdAt` | string | ISO 8601 creation timestamp | | `data[].updatedAt` | string | ISO 8601 timestamp of the last update | | `data[].nodes` | Node[] | Nodes in execution order. `type` is one of evaluate, process, repurpose, quality-check, seo, image-generation, translate, publish, social-distribution | | `data[].nodes[].id` | string | Node id | | `data[].nodes[].type` | string | Node type | | `data[].nodes[].label` | string | Display label | | `data[].nodes[].order` | number | Execution order | | `data[].nodes[].enabled` | boolean | Disabled nodes are skipped | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f7a0", "name": "Daily AI digest", "description": "Evaluate new articles, write stories, check quality, translate to German.", "isActive": true, "isDefault": true, "topicKey": "tech", "triggerConfig": { "type": "scheduled", "schedule": { "frequency": "daily", "days": [ "mon", "tue", "wed", "thu", "fri", "sat", "sun" ], "times": [ "06:00" ], "timezone": "Europe/London" } }, "scheduleState": { "nextRunAt": "2026-09-06T05:00:00.000Z", "lastTriggeredAt": "2026-09-05T05:00:00.000Z", "lastRunId": "68b9b7c2f1a2b3c4d5e6f7b0", "lastSkipReason": null }, "nodes": [ { "id": "68b9b7c2f1a2b3c4d5e6f7a1", "type": "evaluate", "label": "Evaluate articles", "order": 0, "enabled": true }, { "id": "68b9b7c2f1a2b3c4d5e6f7a2", "type": "repurpose", "label": "Write stories", "order": 1, "enabled": true }, { "id": "68b9b7c2f1a2b3c4d5e6f7a3", "type": "quality-check", "label": "Quality check", "order": 2, "enabled": true }, { "id": "68b9b7c2f1a2b3c4d5e6f7a4", "type": "translate", "label": "Translate", "order": 3, "enabled": true } ], "createdAt": "2026-08-01T09:00:00.000Z", "updatedAt": "2026-09-02T18:00:00.000Z" } ], "meta": { "page": 0, "limit": 20, "hasMore": false } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/workflows?isActive=true" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Get Workflow `GET /v1/workflows/{id}` · operationId `getWorkflow` · scopes: `workflows:read` One workflow with its full graph: nodes (with their configuration and canvas position) and the edges connecting them. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The workflow id | **Responses** - `200` The workflow graph | Field | Type | Description | | --- | --- | --- | | `data` | Workflow | The workflow | | `data.id` | string | Unique workflow identifier | | `data.name` | string | Workflow name | | `data.description` | string \| null | Description | | `data.isActive` | boolean | Inactive workflows cannot be run | | `data.isDefault` | boolean | The Topic Workspace's default workflow | | `data.topicKey` | string | Topic Workspace the workflow belongs to | | `data.feedIds` | string[] \| undefined | The workflow's own feed list, up to 10 feed ids. Absent when the workflow reads the Topic Workspace's followed feeds | | `data.triggerConfig` | object | `{ type: manual \| scheduled, schedule?: { frequency, days, times, timezone } }` | | `data.scheduleState` | object \| null | Server managed schedule state: `nextRunAt`, `lastTriggeredAt`, `lastRunId`, `lastSkipReason` | | `data.createdAt` | string | ISO 8601 creation timestamp | | `data.updatedAt` | string | ISO 8601 timestamp of the last update | | `data.nodes` | Node[] | Nodes in execution order. `type` is one of evaluate, process, repurpose, quality-check, seo, image-generation, translate, publish, social-distribution | | `data.nodes[].id` | string | Node id | | `data.nodes[].type` | string | Node type | | `data.nodes[].label` | string | Display label | | `data.nodes[].order` | number | Execution order | | `data.nodes[].enabled` | boolean | Disabled nodes are skipped | | `data.nodes[].config` | object | Node configuration (thresholds, languages, providers, prompts) | | `data.nodes[].position` | object | Canvas position `{ x, y }` | | `data.edges` | Edge[] | Connections: `{ id, sourceNodeId, targetNodeId }` | ```json { "data": { "id": "68b9b7c2f1a2b3c4d5e6f7a0", "name": "Daily AI digest", "description": "Evaluate new articles, write stories, check quality, translate to German.", "isActive": true, "isDefault": true, "topicKey": "tech", "triggerConfig": { "type": "scheduled", "schedule": { "frequency": "daily", "days": [ "mon", "tue", "wed", "thu", "fri", "sat", "sun" ], "times": [ "06:00" ], "timezone": "Europe/London" } }, "scheduleState": { "nextRunAt": "2026-09-06T05:00:00.000Z", "lastTriggeredAt": "2026-09-05T05:00:00.000Z", "lastRunId": "68b9b7c2f1a2b3c4d5e6f7b0", "lastSkipReason": null }, "nodes": [ { "id": "68b9b7c2f1a2b3c4d5e6f7a1", "type": "evaluate", "label": "Evaluate articles", "order": 0, "enabled": true, "position": { "x": 0, "y": 0 }, "config": { "scoreThreshold": 60, "processLimit": 25 } }, { "id": "68b9b7c2f1a2b3c4d5e6f7a2", "type": "repurpose", "label": "Write stories", "order": 1, "enabled": true, "position": { "x": 320, "y": 0 }, "config": { "languageCode": "en-US" } }, { "id": "68b9b7c2f1a2b3c4d5e6f7a3", "type": "quality-check", "label": "Quality check", "order": 2, "enabled": true, "position": { "x": 640, "y": 0 }, "config": { "effort": "normal" } }, { "id": "68b9b7c2f1a2b3c4d5e6f7a4", "type": "translate", "label": "Translate", "order": 3, "enabled": true, "position": { "x": 960, "y": 0 }, "config": { "targetLanguages": [ "de-DE" ] } } ], "createdAt": "2026-08-01T09:00:00.000Z", "updatedAt": "2026-09-02T18:00:00.000Z", "edges": [ { "id": "68b9b7c2f1a2b3c4d5e6f7c1", "sourceNodeId": "68b9b7c2f1a2b3c4d5e6f7a1", "targetNodeId": "68b9b7c2f1a2b3c4d5e6f7a2" }, { "id": "68b9b7c2f1a2b3c4d5e6f7c2", "sourceNodeId": "68b9b7c2f1a2b3c4d5e6f7a2", "targetNodeId": "68b9b7c2f1a2b3c4d5e6f7a3" }, { "id": "68b9b7c2f1a2b3c4d5e6f7c3", "sourceNodeId": "68b9b7c2f1a2b3c4d5e6f7a3", "targetNodeId": "68b9b7c2f1a2b3c4d5e6f7a4" } ] } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Workflow not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/workflows/68b9b7c2f1a2b3c4d5e6f7a0" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Run Workflow `POST /v1/workflows/{id}/run` · operationId `runWorkflow` · scopes: `workflows:run` Start a run. The run reads the workflow's own feeds (or the Topic Workspace's followed feeds when it has no list) and snapshots their unevaluated articles as its input unless you pass explicit ids. Only one run can be active per organization: starting another answers 409 with the active run id. Follow progress with the workflow run endpoints. Runs that write stories cost AI credits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The workflow id | **Request body** (JSON): Optional input | Name | Type | Required | Description | | --- | --- | --- | --- | | `input` | object | no | `{ feedArticleIds?: string[], storyIds?: string[] }` to run on specific items instead of the unevaluated pool | ```json {} ``` **Responses** - `202` Run started | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.runId` | string | The new run | | `data.workflowId` | string | The workflow | | `data.status` | string | queued or running | | `message` | string | Success message | ```json { "success": true, "data": { "runId": "68b9b7c2f1a2b3c4d5e6f7b0", "workflowId": "68b9b7c2f1a2b3c4d5e6f7a0", "status": "running" }, "message": "Workflow run started" } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Workflow not found - `409` Conflict - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/workflows/68b9b7c2f1a2b3c4d5e6f7a0/run" \ -H "Authorization: Bearer nfk_xxx..." \ -H "Content-Type: application/json" \ -d '{}' ``` ### List Workflow Runs `GET /v1/workflows/{id}/runs` · operationId `listWorkflowRuns` · scopes: `workflows:read` Runs of one workflow, newest first. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The workflow id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string (`queued`, `running`, `paused`, `completed`, `failed`, `cancelled`) | no | Only runs in this status | | `limit` | number | no | Page size (default 20, max 100) | | `page` | number | no | Zero-based page number (default 0) | | `order` | string (`createdAt DESC`, `startedAt DESC`, `completedAt DESC`) | no | Sort order as `field ASC\|DESC` (default createdAt DESC). Allowed fields: createdAt, startedAt, completedAt. | **Responses** - `200` A page of runs | Field | Type | Description | | --- | --- | --- | | `data` | WorkflowRun[] | Runs | | `data[].id` | string | Run id | | `data[].workflowId` | string | Workflow that ran | | `data[].status` | string | queued, running, paused, completed, failed or cancelled | | `data[].triggeredBy` | string | manual or scheduled | | `data[].topicKey` | string | Topic Workspace the run worked on | | `data[].input` | object | Snapshot the run started from: `feedArticleIds[]`, `storyIds[]` | | `data[].output` | object \| null | `processedCount`, `filteredCount`, `failedCount`, and on a completed run whose steps lost items `issues[]` (`nodeId`, `label`, `failed`, `passed`): the run still produced output, so it is completed, not failed | | `data[].error` | string \| null | Failure reason | | `data[].metadata` | object | `totalDuration` in seconds and per node overrides | | `data[].startedAt` | string \| null | ISO 8601 start time | | `data[].completedAt` | string \| null | ISO 8601 end time | | `data[].createdAt` | string | ISO 8601 creation timestamp | | `meta` | object | Pagination metadata | | `meta.page` | number | Zero-based page returned | | `meta.limit` | number | Page size used | | `meta.hasMore` | boolean | Whether a next page may exist (the page was full) | | `meta.workflowId` | string | The workflow | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f7b0", "workflowId": "68b9b7c2f1a2b3c4d5e6f7a0", "status": "completed", "triggeredBy": "scheduled", "topicKey": "tech", "input": { "feedArticleIds": [ "68b9b7c2f1a2b3c4d5e6f780", "68b9b7c2f1a2b3c4d5e6f781" ] }, "output": { "processedCount": 2, "filteredCount": 1, "failedCount": 0 }, "error": null, "metadata": { "totalDuration": 509 }, "startedAt": "2026-09-05T05:00:02.000Z", "completedAt": "2026-09-05T05:08:31.000Z", "createdAt": "2026-09-05T05:00:01.000Z" } ], "meta": { "page": 0, "limit": 20, "hasMore": false, "workflowId": "68b9b7c2f1a2b3c4d5e6f7a0" } } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/workflows/68b9b7c2f1a2b3c4d5e6f7a0/runs" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Get Workflow Run `GET /v1/workflow-runs/{runId}` · operationId `getWorkflowRun` · scopes: `workflows:read` One run with its status, input snapshot, output counts and timing. Poll it while a run is active; list its node runs for per-step progress. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `runId` | string | yes | The workflow run id | **Responses** - `200` The run | Field | Type | Description | | --- | --- | --- | | `data` | WorkflowRun | The run | | `data.id` | string | Run id | | `data.workflowId` | string | Workflow that ran | | `data.status` | string | queued, running, paused, completed, failed or cancelled | | `data.triggeredBy` | string | manual or scheduled | | `data.topicKey` | string | Topic Workspace the run worked on | | `data.input` | object | Snapshot the run started from: `feedArticleIds[]`, `storyIds[]` | | `data.output` | object \| null | `processedCount`, `filteredCount`, `failedCount`, and on a completed run whose steps lost items `issues[]` (`nodeId`, `label`, `failed`, `passed`): the run still produced output, so it is completed, not failed | | `data.error` | string \| null | Failure reason | | `data.metadata` | object | `totalDuration` in seconds and per node overrides | | `data.startedAt` | string \| null | ISO 8601 start time | | `data.completedAt` | string \| null | ISO 8601 end time | | `data.createdAt` | string | ISO 8601 creation timestamp | ```json { "data": { "id": "68b9b7c2f1a2b3c4d5e6f7b0", "workflowId": "68b9b7c2f1a2b3c4d5e6f7a0", "status": "completed", "triggeredBy": "scheduled", "topicKey": "tech", "input": { "feedArticleIds": [ "68b9b7c2f1a2b3c4d5e6f780", "68b9b7c2f1a2b3c4d5e6f781" ] }, "output": { "processedCount": 2, "filteredCount": 1, "failedCount": 0 }, "error": null, "metadata": { "totalDuration": 509 }, "startedAt": "2026-09-05T05:00:02.000Z", "completedAt": "2026-09-05T05:08:31.000Z", "createdAt": "2026-09-05T05:00:01.000Z" } } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Workflow run not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/workflow-runs/68b9b7c2f1a2b3c4d5e6f7b0" \ -H "Authorization: Bearer nfk_xxx..." ``` ### List Node Runs `GET /v1/workflow-runs/{runId}/node-runs` · operationId `listNodeRuns` · scopes: `workflows:read` Per node progress of a run in execution order, with item counts and the produced items (article and story ids) in `metadata.outputItems`. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `runId` | string | yes | The workflow run id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string (`pending`, `draft`, `preparing`, `executing`, `paused`, `finished`, `failed`, `skipped`, `cancelled`) | no | Only node runs in this status | **Responses** - `200` Node runs | Field | Type | Description | | --- | --- | --- | | `data` | NodeRun[] | Node runs | | `data[].id` | string | Node run id | | `data[].workflowRunId` | string | Parent run | | `data[].workflowNodeId` | string | Node definition | | `data[].nodeType` | string | evaluate, process, repurpose, quality-check, seo, image-generation, translate, publish, social-distribution | | `data[].nodeLabel` | string | Node label at run time | | `data[].status` | string | pending, draft, preparing, executing, paused, finished, failed, skipped or cancelled | | `data[].progress` | number | 0 to 100 | | `data[].itemsIn` | number | Items received | | `data[].itemsOut` | number | Items produced | | `data[].itemsPassed` | number | Items that passed the node's filter | | `data[].itemsFailed` | number | Items that failed | | `data[].error` | string \| null | Failure reason | | `data[].metadata` | object | `duration` in seconds, `llmTokensUsed`, and `outputItems[]` (the produced articles or stories with their ids) | | `data[].startedAt` | string \| null | ISO 8601 start time | | `data[].completedAt` | string \| null | ISO 8601 end time | | `meta.count` | number | Node runs returned | | `meta.runId` | string | The run | ```json { "data": [ { "id": "68b9b7c2f1a2b3c4d5e6f7d0", "workflowRunId": "68b9b7c2f1a2b3c4d5e6f7b0", "workflowNodeId": "68b9b7c2f1a2b3c4d5e6f7a2", "nodeType": "repurpose", "nodeLabel": "Write stories", "status": "finished", "progress": 100, "itemsIn": 2, "itemsOut": 2, "itemsPassed": 2, "itemsFailed": 0, "error": null, "metadata": { "duration": 301, "llmTokensUsed": 18402, "outputItems": [ { "id": "68b9b7c2f1a2b3c4d5e6f700", "type": "story", "data": { "title": "AI Revolution in Healthcare" } } ] }, "startedAt": "2026-09-05T05:01:00.000Z", "completedAt": "2026-09-05T05:06:01.000Z" } ], "meta": { "count": 1, "runId": "68b9b7c2f1a2b3c4d5e6f7b0" } } ``` - `400` Validation error: the request is missing or misusing a field - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Workflow run not found **Example** ```bash curl -X GET "https://client-api.news-factory.app/v1/workflow-runs/68b9b7c2f1a2b3c4d5e6f7b0/node-runs" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Stop Run `POST /v1/workflow-runs/{runId}/cancel` · operationId `cancelWorkflowRun` · scopes: `workflows:run` Stop a queued, running or paused run. The current node finishes its in-flight operation, then the run stops and its status becomes `cancelled`. Finished runs answer 409. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `runId` | string | yes | The workflow run id | **Responses** - `200` Workflow run cancelled | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.runId` | string | The run | | `data.status` | string | New run status | | `message` | string | Success message | ```json { "success": true, "data": { "runId": "68b9b7c2f1a2b3c4d5e6f7b0", "status": "cancelled" }, "message": "Workflow run cancelled" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Workflow run not found - `409` Conflict - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/workflow-runs/68b9b7c2f1a2b3c4d5e6f7b0/cancel" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Pause Run `POST /v1/workflow-runs/{runId}/pause` · operationId `pauseWorkflowRun` · scopes: `workflows:run` Pause a running run before its next node starts. Resume it later or stop it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `runId` | string | yes | The workflow run id | **Responses** - `200` Workflow run paused | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.runId` | string | The run | | `data.status` | string | New run status | | `message` | string | Success message | ```json { "success": true, "data": { "runId": "68b9b7c2f1a2b3c4d5e6f7b0", "status": "paused" }, "message": "Workflow run paused" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Workflow run not found - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/workflow-runs/68b9b7c2f1a2b3c4d5e6f7b0/pause" \ -H "Authorization: Bearer nfk_xxx..." ``` ### Resume Run `POST /v1/workflow-runs/{runId}/resume` · operationId `resumeWorkflowRun` · scopes: `workflows:run` Resume a paused run from the node it paused on. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `runId` | string | yes | The workflow run id | **Responses** - `200` Workflow run resumed | Field | Type | Description | | --- | --- | --- | | `success` | boolean | Always true | | `data.runId` | string | The run | | `data.status` | string | New run status | | `message` | string | Success message | | `data.workflowId` | string | The workflow | | `data.resumeNodeId` | string | Node the run continues from | ```json { "success": true, "data": { "runId": "68b9b7c2f1a2b3c4d5e6f7b0", "status": "running", "workflowId": "68b9b7c2f1a2b3c4d5e6f7a0", "resumeNodeId": "68b9b7c2f1a2b3c4d5e6f7a3" }, "message": "Workflow run resumed" } ``` - `401` Unauthorized: missing, invalid, revoked or expired credentials - `403` The API key lacks a scope this endpoint needs - `404` Workflow run not found - `409` Conflict - `503` API keys cannot be verified or signed right now; retry in a minute **Example** ```bash curl -X POST "https://client-api.news-factory.app/v1/workflow-runs/68b9b7c2f1a2b3c4d5e6f7b0/resume" \ -H "Authorization: Bearer nfk_xxx..." ```