#Publishing Articles Through Site Modules
Status: Available for sites on Bitrix (module
sapport.connector1.2.1) and WordPress (modulesapport-connector1.1.1). Packages for FastAPI and Nuxt have not been released publicly. Today a publication is ordered in the Sapport dashboard; there is no public server API for publishing (see the articles API, status Planned).
A site module is a small plugin that you bind to your site. Every few minutes it asks the platform whether there are jobs for it, verifies the job signature, applies the job to your CMS, and reports the outcome. This page explains how a job is structured, how the platform protects your site, and what to do about conflicts. It is meant for both the site owner and the developer of an adapter.
Access: in the dashboard, the seo.manage permission to publish and seo.view to view. Paid: no. PII: no.
#How it works
- You bind the site: the dashboard creates a one-time binding code, and you enter it in the module. The platform issues the site a secret (shown once).
- In the dashboard you choose an article and click "Publish to site via module", specifying the mode: draft or publish.
- The platform creates a job, signs it, and queues it for your site.
- The module picks up jobs (it polls every 10 minutes; the hourly heartbeat reports the number of pending jobs), verifies the signature, and applies the job.
- The module returns the outcome:
appliedwith the page address, orskipped,conflict, orfailedwith a reason code. In the dashboard, the article and the site show the status, the page address, the record ID in your CMS, and the error.
The platform address (api_base) for the module is the domain of the binding cell: RU https://formula-cream.pro, EN https://wfacademy.org, ID https://wfacademy.id. The cell is determined by the prefix of the binding code.
The full cloud-to-module protocol (binding, heartbeat, job polling, outcome, unbinding, HMAC request signing) and the error codes are described in
PROTOCOL.mdandERRORS.mdin the connector package. This page covers what you need to understand article publishing.
#The job
| Field | Type | Description |
|---|---|---|
job_id | string (uuid) | Job ID |
kind | "article" | "post" | Source: an article or a post |
action | "upsert" | "unpublish" | Create or update / take down from the site |
source_id | string (uuid) | The article ID on the platform |
version | integer | The version of this source's publication on this site |
payload | object | The content (below) |
payload_sha256 | string | Hash of the canonical JSON of the payload |
kid | string | Signing key ID |
sig | string | Ed25519 signature in base64 |
issued_at | integer | Issue time, Unix seconds |
expect_cms_id | string, optional | The ID of the CMS record the platform expects to update; present only if the platform has already applied this source on this site |
#Content (payload)
| Field | Type | Description |
|---|---|---|
title | string | Title |
slug | string | Page address (human-readable URL); applied only on creation |
html | string | The article's finished, sanitized HTML |
excerpt | string | Teaser. For an article, its meta description (for platform articles, the blog teaser comes first); for a post, an empty string |
cover_url | string | null | Cover image: an absolute https address on the cell domain |
cover_alt | string | null | Cover caption; today equal to the title |
seo | object | title, description, canonical (always null today) |
tags | array of string | Up to 10 tags, each up to 50 characters |
category_hint | string | null | Section hint |
locale | string | Article language |
publish_mode | "draft" | "publish" | Requested mode |
scheduled_at | integer | null | Deferred publishing; always null today |
recreate | true, optional | Recreate a record that was deleted on the site |
{
"job_id": "00000000-0000-4000-8000-000000000001",
"kind": "article",
"action": "upsert",
"source_id": "11111111-1111-4111-8111-111111111111",
"version": 1,
"payload": {
"title": "Пептиды: полный гид",
"slug": "peptidy-polnyj-gid",
"html": "<p>Текст статьи</p>",
"excerpt": "",
"cover_url": "https://formula-cream.pro/storage/v1/object/public/sapport-files/covers/a.webp",
"cover_alt": "Пептиды: полный гид",
"seo": { "title": "Пептиды", "description": "Описание", "canonical": null },
"tags": ["пептиды", "уход"],
"category_hint": "ingredients",
"locale": "ru",
"publish_mode": "draft",
"scheduled_at": null
},
"payload_sha256": "bbaee6d585670f93302af42addc4d8c2cb70638d1571e4616576a492fe0d26d1",
"kid": "test-1",
"sig": "mHgK/Ud/fbDzwnjpKiSKtyi49Mzth+sOOWc+ti2oXYHzzu0CtX92SxKVVH3aNYfuiatMnakLIsODBhieLRHfBw==",
"issued_at": 1790000000
}
The example comes from the package's test vectors (the test-1 key is a test key and is not used by production cells).
#Limits
| What | Limit |
|---|---|
| Article HTML | at most 2,000,000 UTF-8 bytes |
The whole payload as JSON | at most 3,000,000 bytes |
| Cover image | JPEG, PNG, or WebP, up to 5 MB |
| Tags | up to 10, up to 50 characters each |
| Signature age | 7 days; older is skipped expired |
An article that exceeds a limit does not become a job: the dashboard shows the outcome "too large" or "empty".
Relative image and link addresses in the HTML are converted to absolute ones on the cell domain. The module downloads media only from the platform address (api_base), without redirects: a cover from a foreign CDN ends the job with the error media_foreign_origin.
#Job signature
Every job is signed with the cell's Ed25519 private key. This protects your site from a job being tampered with in transit and from jobs meant for other sites:
- what is signed is the canonical JSON of the envelope
{action, issued_at, job_id, kind, payload_sha256, site_id, source_id, version}(plusexpect_cms_id, if present); site_idin the envelope is the ID of your site from the binding, so a job for another site fails verification;kidshows which key signed; the ID prefix indicates the cell:ru-,en-,id-;- the cells' public keys are published in the file
integrations/common/job-keys.jsonof the connector package, one perkid. If the module does not know akid(the key was rotated), it reports "module update needed" and does not discard the job: the platform will issue it again.
Canonical JSON: object keys are sorted by UTF-16 code units, recursively, without whitespace; numbers are integers only, within ±(2^53−1); nesting is no deeper than 32. Fractional numbers, lone surrogates, and duplicate keys are rejected. Test vectors and the implementation are in the connector package (the tests/_vectors/ directory).
The signing of the module's own requests to the platform (HMAC-SHA256 with the headers X-Sapport-Site, X-Sapport-Ts, X-Sapport-Nonce, X-Sapport-Sig, a ±300-second window) is a separate site-binding mechanism; it is described in the connector protocol.
#What the module does with the content
| Job field | Bitrix | WordPress |
|---|---|---|
| Title | NAME | post_title |
| Body | DETAIL_TEXT (allowed-tags list) | post_content (wp_kses_post) |
| Teaser | PREVIEW_TEXT | post_excerpt |
Address (slug) | CODE, only on creation | post_name, only on creation |
| Tags | TAGS | Tags |
| Section | From the allowed sections, by hint | Category |
| SEO | The element's SEO templates | Yoast or Rank Math |
| Draft, publish | ACTIVE | draft, publish, future |
| Cover image | The element's images | The post's featured image |
| Taking down from the site | ACTIVE = N | Status draft |
In any case, the module sanitizes the HTML itself, independently of the sanitizing on the platform.
#Mode ceiling
The site owner can restrict the module to "drafts only". A publish job is then applied as a draft, and an article that a person has already published on the site returns the conflict published_awaits_approval.
#Versions
- Every change to a source (editing the article and sending it again) creates a new version of the job:
max + 1. - Resending an unchanged article does not create a new version: the dashboard answers "already in progress".
- Stale versions that have not been issued are marked
superseded. - A version that is not newer than the one already applied gets
skipped stale_version; this is a normal result for retries. - Taking down (
unpublish) does not create a new version and is allowed only if the site already has an applied publication; otherwise it is refused with "not published". - "Recreate" means sending with
recreate: trueand withoutexpect_cms_id, when the record was deleted on the site and you want to publish it again.
#Job outcome
The module returns one outcome per job; the first recorded outcome is final and is not overwritten.
| Outcome | Fields | Meaning |
|---|---|---|
applied | cms_id, url | Applied; url is present for a published record |
skipped | error | Skipped (stale version, signature age, nothing to take down) |
conflict | error | Conflict with a page on the site |
failed | error | Error |
Field requirements: cms_id matches ^[A-Za-z0-9_-]{1,64}$; url is http or https only; error is up to 500 characters. The module does not send transient failures (database, network, lost lease): the job returns in 15 minutes.
#Conflict and skip codes
| Code | Outcome | Meaning | What to do |
|---|---|---|---|
stale_version | skipped | The version is not newer than the applied one | Nothing: normal for retries |
expired | skipped | The signature is more than 7 days old | Create the job again |
deleted_on_site | skipped (on take-down) / conflict (on publish) | The record was deleted on the site | For publishing, "Recreate" |
not_found | skipped | A source that was not published here is being taken down | Nothing |
edited_on_site | conflict | The article was edited on the site after the module wrote it | Update manually, or revert the edit and send again |
published_awaits_approval | conflict | The ceiling is "draft", but the record was already published by a person | Raise the ceiling or move it manually |
cms_id_mismatch | conflict | The platform expects a different record | Contact Sapport support |
issued_in_future | failed | The site clock is behind by more than 5 minutes | Fix the server time |
The full list (signature, cover image, site database, section setup) is in ERRORS.md in the connector package.
#What you see in the dashboard
On the article and on the site (the "Publications" block): the title, publication mode, job status (queued, issued, applied, skipped, conflict, error), page address, CMS record ID, and error text. The job list is read by staff members with the seo.view, seo.manage, or admin.api_keys permission.
#There is no public API for this yet
The dashboard publishing routes require a staff session and are not intended for external systems. A server-side "publish" call with a key is part of the future articles API: POST /articles/{id}/publish with target: connector.
#Open questions
scheduled_at(technical). Alwaysnulltoday; a module with deferred publishing (futurein WordPress) will receive it only once the platform starts passing it.canonical(technical). Alwaysnull: managing the canonical address on the platform side is not implemented yet.- FastAPI and Nuxt packages (for the owner). Not released publicly; whether they will become public has not been decided.
- Alias domain (technical). A site on a domain that is neither bound nor a
wwwvariant of the bound one gets400on signed requests; the behavior is not fixed in the binding documentation. - Public address of
job-keys.json(technical). The file lives in the package repository; a public download address is not defined.