From a private draft to a confirmed publication.
Create and edit articles, choose an approved cover, then publish a saved version. Every step leaves a version you can check and a result you can distinguish from the next step.
Use the canonical origin directly. signal.aeneia.pl is a redirecting mirror. The public article library and feed are anonymous resources; this authenticated API also returns private drafts.
A saved draft remains private. A publication response of 202 confirms a saved publication snapshot. Only an observed matching public revision produces publication.state: live.
Authentication
Use a secret supplied out of band by the owner. One key grants read, write and publish access. Keep it in a server-side secret store; do not put it in source code, URLs, browser scripts or logs.
Authorization: Bearer <key-from-your-secret-store>The server stores a SHA-256 digest in SIGNAL_API_KEY_HASH and compares it with a timing-safe operation. Missing configuration fails closed with 503; missing or incorrect authentication returns 401.
This is a server-to-server API without browser CORS. Any Origin header returns 403. Any Cookie header, including one alongside valid Bearer authentication, also returns 403. The browser CMS keeps its separate session API and CSRF protection.
Private responses use Cache-Control: no-store and X-Robots-Tag: noindex, nofollow. Every response carries Signal-API-Version: 1 and a request ID. Public discovery files contain no key.
Endpoints
| Method | Path after /api/v1 | Purpose |
|---|---|---|
| GET | /articles | List drafts and saved publication snapshots, with filters and pagination. |
| POST | /articles | Create a private draft. Requires Idempotency-Key. |
| GET | /articles/{slug} | Read the draft, publication snapshot, version, ETag and short history. |
| PUT | /articles/{slug} | Replace the complete draft. Requires the current ETag or version. |
| POST | /articles/{slug}/publish | Validate the saved draft and request publication in Production. |
| POST | /publications | Publish 1–500 saved versions atomically, including interlinked new articles, with one shared revision. |
| GET | /media | Find an existing approved cover and its dimensions and alt. |
| GET | /status | Compare the catalog revision with the observed public build revision. |
There is no permanent deletion endpoint, arbitrary file upload or slug rename. DELETE returns 405. The media library is read-only.
Ordered recommendations and reader image
meta.relatedSlugs[0] and [1] select recommendation slots 1 and 2 in the reader rail and after roughly one third and two thirds of complete paragraphs. Keep their order; publication still requires at least three unique related targets and five contextual body links. List responses include optional publishedSummary from the public snapshot, never its newer draft title or body.
meta.interludeImage: omit or use auto for an approved automatic image, none to disable, or an existing media id such as P08. Explicit auto is normalized to omission. Arbitrary URLs are rejected. Reader modules are not copied into Markdown.
Release an interlinked collection
Save and review the drafts first. POST /publications with an Idempotency-Key and an explicit list of current versions:
{"articles":[{"slug":"first-guide","version":3},{"slug":"second-guide","version":2}]}These are example slugs and versions. A release contains 1–500 unique saved drafts. The server validates the entire resulting publication graph, so new entries can link to each other. One stale version, missing target or invalid document aborts the whole release. Unlisted drafts remain private. Successful validation saves one revision and the differential SEO outbox before dispatching one leased deploy hook.
A 202 response accepts snapshots; inspect publication.state, the actual public pages and seo separately. After a transport failure retry the same key and slug/version set; ordering does not matter. Replays do not publish later draft edits. The ordinary external-hook uncertainty and 24-hour idempotency limits still apply. Production only, with the same Bearer, Origin/Cookie restrictions and rate limit. Do not send a shared If-Match: versions belong to individual articles.
A practical draft workflow
- Read
/mediaand select a cover. Use its relative/images/…URL inmeta.image. - Create a draft with a unique slug and an Idempotency-Key. A draft needs a valid slug and category; the remaining content may still be incomplete.
- GET the article, make the reviewed changes and PUT the complete document with its current ETag.
- When publication is authorised, POST to
/{slug}/publishwith that version and a separate publication key. - Read
/status. If exact page content matters, also inspect the public article after the requested revision is live.
{
"document": {
"meta": {
"slug": "my-article",
"category": "Design",
"title": "A private working draft"
},
"body": "## First section\n\nDraft material to review."
}
}This short example is a valid draft shape, not a publication-ready article. Body is Markdown without frontmatter. Unknown metadata is removed and draft status is enforced. Categories are Design, Audit, Oriens and Imperium. Reserved slugs are rejected.
Publication applies the shared CMS gate: complete metadata, dates, at least 1000 words and three H2 sections, approved cover, related articles, five distinct contextual article links and the correct offer CTA. A 422 validation response changes no publication snapshot.
Versions and ETags
GET returns a version number and a strong ETag such as "v3". PUT and publish need the current value in If-Match, or a numeric version in the body. If both are present, they must agree.
If-Match: "v3"Missing condition: 428. Weak ETag or wildcard: 400. A stale If-Match: 412; a stale body version: 409. Read again and reconcile the edits. Do not replace a newer document automatically.
Each save and publication increments the article version. A draft edit preserves the existing publication snapshot. The private catalog itself is written conditionally with Blob ETags, so another article's concurrent edit can be retained while a change to this article is rejected. Five earlier drafts are retained as short history, not as a complete backup.
Safe retries
Create and publish require an Idempotency-Key with 8–128 letters, digits or ._:-, starting with a letter or digit. Use a new identifier for a new intended operation. On a lost response, retain the same key, accepted document and version.
The key's hash and operation result are saved atomically in the private catalog for 24 hours. A matching retry returns the original slug, version and revision with Idempotency-Replayed: true. A changed request under the same key returns 409. Capacity is 2048 unexpired records; reaching it returns 503 rather than forgetting an active key.
A publish retry does not create another saved publication revision or publish a subsequently edited draft. It can retry an unconfirmed hook. A durable 30-second lease coordinates concurrent dispatch and allows recovery after interruption.
The external build is not promised exactly once. If the hook was accepted just before the function lost its receipt, a retry may request another build of the same snapshot. The saved publication revision remains the same.
PUT relies on version checks. If its response is uncertain, GET and compare the result before trying to replace the draft again.
Publication status you can interpret
Publish is available only when the function runs in Production with a configured Vercel deploy hook. It uses the already saved draft, refreshes its modified date and returns 202 after storing the snapshot.
| publication.state | Observed meaning |
|---|---|
pending | The public status was read successfully, but its revision differs. |
live | The public status contains exactly the requested revision. |
unknown | The public status could not be confirmed. This is inconclusive. |
delivery describes the hook separately: pending, dispatching, queued, retryable or superseded. A queued hook does not establish a live page. Retryable allows the original publish request to be retried. Superseded means a newer publication request exists in the catalog.
The API checks one fixed origin: https://blog.aeneia.pl/cms-status.json, with a five-second timeout, no cache and no redirects. It accepts no user-supplied status URL and does not infer a deployment Git SHA. Draft-only edits do not change the publication revision.
Filters and limits
Article lists support status=all|draft|published|dirty, category, q, limit and offset. Draft means no publication snapshot; published means a saved snapshot, including one awaiting a build; dirty means the draft differs. Both lists support search up to 120 characters, limit 1–100 (default 20) and offset 0–1000000. Repeated or unknown parameters return 400.
Lists return items, total, limit, offset and nextOffset. Article order is by slug; the collection may change between requests.
- Payload: at most 240000 UTF-8 bytes; the shared document also limits body to 180000 characters.
- Rate: 120 requests per IP per 60 seconds, including incorrect Bearer attempts. Respect Retry-After on 429.
- Storage: rate counters are durable private Blob records keyed by HMAC-SHA256, without storing a raw IP. A storage outage fails closed.
RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset accompany accepted requests. Reset is a Unix timestamp in seconds. API clients should use canonical HTTPS directly and refuse redirects carrying credentials.
Client examples
These examples read the key from SIGNAL_API_KEY. They contain no live credential. Prepare article.json using the document shape above.
curl
curl --fail-with-body --max-redirs 0 \
-H "Authorization: Bearer $SIGNAL_API_KEY" \
'https://blog.aeneia.pl/api/v1/articles?status=dirty&limit=20'
curl --fail-with-body --max-redirs 0 \
-H "Authorization: Bearer $SIGNAL_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: article-create-example-001' \
--data-binary @article.json \
'https://blog.aeneia.pl/api/v1/articles'For an authorised publication, use the actual ETag from GET. The value below is illustrative.
curl --fail-with-body --max-redirs 0 \
-H "Authorization: Bearer $SIGNAL_API_KEY" \
-H 'Content-Type: application/json' \
-H 'If-Match: "v3"' \
-H 'Idempotency-Key: article-publish-example-001' \
--data '{}' \
'https://blog.aeneia.pl/api/v1/articles/my-article/publish'
Python
import json, os, urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
client = urllib.request.build_opener(NoRedirect)
base = 'https://blog.aeneia.pl/api/v1'
headers = {'Authorization': 'Bearer ' + os.environ['SIGNAL_API_KEY']}
with client.open(urllib.request.Request(
base + '/articles/my-article', headers=headers)) as response:
article = json.load(response)
etag = response.headers['ETag']
article['document']['body'] += '\n\nReviewed information.'
req = urllib.request.Request(
base + '/articles/my-article', method='PUT',
headers={**headers, 'Content-Type': 'application/json', 'If-Match': etag},
data=json.dumps({'document': article['document']}).encode('utf-8'))
with client.open(req) as response:
saved = json.load(response) # still a private draft
JavaScript · Node.js
const base = 'https://blog.aeneia.pl/api/v1';
const headers = {Authorization: `Bearer ${process.env.SIGNAL_API_KEY}`};
const response = await fetch(`${base}/articles/my-article`, {
headers, redirect: 'error', signal: AbortSignal.timeout(15000)
});
if (!response.ok) throw new Error(`Read failed: ${response.status}`);
const article = await response.json();
const saved = await fetch(`${base}/articles/my-article`, {
method: 'PUT', redirect: 'error', signal: AbortSignal.timeout(15000),
headers: {...headers, 'Content-Type': 'application/json',
'If-Match': response.headers.get('etag')},
body: JSON.stringify({document: {...article.document,
body: article.document.body + '\n\nReviewed addition.'}})
});
if (!saved.ok) throw new Error(`Save failed: ${saved.status}`);
// This saved a draft; publication is a separate authorised step.Errors
{
"error": {"code": "version_conflict", "message": "The article changed…"},
"requestId": "request-identifier"
}| HTTP | Action |
|---|---|
| 400 | Correct the JSON, document, query, version or idempotency header. |
| 401 | Supply a valid Bearer key. Cookies cannot authorise this API. |
| 403 | Remove Origin/Cookie, or use Production for publication. |
| 404 / 405 | Check the resource and allowed method. Permanent deletion is unavailable. |
| 409 / 412 | Read and reconcile a version conflict; never change an idempotency key merely to bypass a conflict. |
| 413 / 415 | Reduce the UTF-8 payload or use application/json. |
| 422 | Fix the publication validation problem and save the corrected draft. |
| 428 | Send the current If-Match or version. |
| 429 | Wait for Retry-After. |
| 503 | No success is implied. Retry safely with the same create/publish key. |
The machine contract and complete schemas are in OpenAPI. Error details do not include credentials, storage errors or deploy-hook URLs. Use the request ID when asking the site owner to investigate.