Aeneia Signal

Agent API v1 · Private editorial work, with an explicit publication step

API v1

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.

Canonical origin: https://blog.aeneia.pl · Base path: /api/v1

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

MethodPath after /api/v1Purpose
GET/articlesList drafts and saved publication snapshots, with filters and pagination.
POST/articlesCreate 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}/publishValidate the saved draft and request publication in Production.
POST/publicationsPublish 1–500 saved versions atomically, including interlinked new articles, with one shared revision.
GET/mediaFind an existing approved cover and its dimensions and alt.
GET/statusCompare 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

  1. Read /media and select a cover. Use its relative /images/… URL in meta.image.
  2. 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.
  3. GET the article, make the reviewed changes and PUT the complete document with its current ETag.
  4. When publication is authorised, POST to /{slug}/publish with that version and a separate publication key.
  5. 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.stateObserved meaning
pendingThe public status was read successfully, but its revision differs.
liveThe public status contains exactly the requested revision.
unknownThe 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"
}
HTTPAction
400Correct the JSON, document, query, version or idempotency header.
401Supply a valid Bearer key. Cookies cannot authorise this API.
403Remove Origin/Cookie, or use Production for publication.
404 / 405Check the resource and allowed method. Permanent deletion is unavailable.
409 / 412Read and reconcile a version conflict; never change an idempotency key merely to bypass a conflict.
413 / 415Reduce the UTF-8 payload or use application/json.
422Fix the publication validation problem and save the corrected draft.
428Send the current If-Match or version.
429Wait for Retry-After.
503No 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.