跳到主要内容
Vistaria
中文
登录 免费扫描
Content API vistaria.app/lab/docs/api

Content API

Version 1 (v1) — stable. Additive changes may ship without notice; any breaking change is released only as a new version. Last revised: 11 September 2026.

1. Introduction and scope

The Content API is Vistaria’s official interface for delivering the output of a Growth Plan into a customer’s website. WordPress sites have a direct connector in the panel and do not need this document; it is intended for sites built on any other stack (Next.js, Nuxt, Astro, Hugo, Laravel, a custom CMS and the like).

The delivery model is push: as soon as an article is ready, Vistaria’s server sends one signed request to the endpoint you have registered. That request carries everything required to publish: the article text (Markdown and HTML), meta title and description, keywords, category, JSON-LD and the cover image inline (base64). Nothing has to be fetched, downloaded or uploaded separately.

Read endpoints are also available so that, after an outage on your side, a site rebuild or an audit, you can list the articles again, retrieve the full content of any of them and report the final publication to us.

The service is part of the Pro tier — the same tier that includes the WordPress connector. The API key only reads and records publication status; it never generates content and never incurs a cost. Treat the key and the signing secret as you would any service credential.

The life cycle of one article

  1. The article is written in the Growth Plan and its cover is produced (automatically on its calendar day, or earlier on request from the panel or Telegram).
  2. Vistaria sends a signed POST with the article.ready event to your endpoint; the body contains the text, the metadata and the cover, and the mode field states whether the article should be stored as a draft or published.
  3. Your service verifies the signature, stores the article and cover in your CMS and answers with a short JSON body giving the resulting status (published, draft or accepted).
  4. If the answer is published with the final URL, we record the publication at once. Otherwise, once you publish, report the final URL through POST /api/v1/articles/{id}/published so the effectiveness review can count it.

2. Authentication

In the panel, open the site page → “Connections” → “Content API” → “Create key”. The key is displayed once; only its SHA-256 hash is stored on our side, so a lost key has to be replaced with a new one. “Rotate key” invalidates the previous key immediately; “Revoke” closes API access entirely.

Send the key in the X-Api-Key header, or as Authorization: Bearer <key>. Every response is UTF-8 JSON and the ok field states the overall result.

Each key belongs to one site and can only access that site’s data. Moving the subscription to a tier without an article quota disables the key until the tier is restored (401 unauthorized).

Keys start with the vs_ prefix so they are recognisable in logs and repositories; the prefix is part of the key and must not be removed.

Rate limit: 120 requests per minute per key. Re-delivery requests are limited separately to 30 per hour. Beyond that, 429 rate_limited is returned with a retry_after field (seconds).

Shell
curl -sS -H "X-Api-Key: vs_…" https://vistaria.app/api/v1/me

3. The delivery endpoint

Register your endpoint URL in the same “Content API” card. The address must be public and served over HTTPS; internal addresses, localhost and private IP ranges are refused. On registration a signing secret (prefixed whsec_) is displayed once. Registering a new URL issues a new secret; registering an empty URL removes the endpoint.

Whenever an article is ready, one POST with a JSON body is sent to this address. The response timeout is 30 seconds. A 2xx response means the delivery succeeded; any other response or a network error records the delivery as failed and it is retried up to three times with increasing back-off. The last error is visible in your panel and in the delivery.last_error field of GET /api/v1/me.

Request headers

HeaderDescription
Content-TypeAlways application/json; charset=utf-8.
X-EventThe event name; in this version only article.ready.
X-Delivery-IdUnique id of this delivery attempt (32 hex characters). Keep it for logs and support requests.
X-TimestampTime of sending, ISO-8601 in UTC. We recommend rejecting requests older than 5 minutes (replay protection).
X-ModeThe requested mode: draft or publish. Same value as the mode field in the body.
Idempotency-KeyA stable key of the form article-{id}. A repeated delivery (retry or re-delivery) carries the same key; use it to avoid creating a duplicate post and update the existing one instead.
X-SignatureHMAC-SHA256 of the raw request body with your secret, as lowercase hex. Verify before any processing (section 3.3).
User-AgentThe sending service identifier, for log filtering.

Request body

The body is a JSON object with the fields below. The article object follows section 7 (full version, including markdown, html, seo and schema_jsonld) and the cover object is described in section 5. For an article without a cover, cover is null.

HTTP request
POST {your-endpoint}
Content-Type: application/json; charset=utf-8
X-Event: article.ready
X-Delivery-Id: 3f2a…c9
X-Timestamp: 2026-09-11T10:00:00Z
X-Mode: draft
Idempotency-Key: article-123
X-Signature: <hex hmac-sha256(secret, raw body)>

{
  "event": "article.ready",
  "api_version": "v1",
  "delivery_id": "3f2a…c9",
  "sent_at": "2026-09-11T10:00:00Z",
  "mode": "draft",                       // draft | publish
  "site": { "id": 42, "website": "https://example.com", "language": "en" },
  "article": {
    "id": 123, "title": "…", "slug": "my-article", "language": "en",
    "status": "ready", "cycle": 1, "planned_for": "2026-09-14",
    "seo": { "meta_title": "…", "meta_description": "…", "keywords": "…",
             "category": "…", "reading_min": 6, "tldr": "…", "internal_links": [] },
    "markdown": "# …",
    "html": "<h2>…",
    "schema_jsonld": "{\"@context\": \"https://schema.org\", …}",
    "guard_note": "",
    "delivery": { "status": "queued", "delivered_at": null, "external_id": null, "last_error": null },
    "url": "https://vistaria.app/api/v1/articles/123"
  },
  "cover": {
    "alt": "…", "primary": "webp", "encoding": "base64",
    "webp": { "filename": "my-article.webp", "content_type": "image/webp", "bytes": 184320,
              "url": "https://vistaria.app/static/lab/covers/42/…webp", "data": "UklGRi…" },
    "jpg":  { "filename": "my-article.jpg",  "content_type": "image/jpeg", "bytes": 262144,
              "url": "https://vistaria.app/static/lab/covers/42/…jpg",  "data": "/9j/4AAQ…" }
  }
}

3.3. Verifying the signature

  1. Obtain the request body as raw bytes, before any parsing or re-encoding. Many frameworks parse the body by default; disable automatic parsing for this route.
  2. Compute HMAC-SHA256 of the raw bytes with the secret and encode the result as hex.
  3. Compare it with the X-Signature header using a constant-time comparison. On mismatch respond with 401 and do not process the body.
  4. Compare X-Timestamp with your server clock and reject a difference above 5 minutes. Then look up Idempotency-Key in your database; if it was already processed, return the same response as before.

3.4. Response contract

After storing the article, respond with 200 or 201 and a short JSON body. The status field is required and takes one of the following values:

ValueMeaning and obligations
publishedThe article is publicly available now. The url field (full page address) is required. We record the publication immediately; no separate call is needed. In publish mode this is the expected answer.
draftThe article was stored unpublished and publishing is up to you. url is optional (preferably the editor link). Once published, report the final URL through POST /api/v1/articles/{id}/published.
acceptedThe request was received and is being processed asynchronously (a build queue, for example). As with draft, the final publication must be reported through POST /api/v1/articles/{id}/published. A 2xx response with no body, or without a status field, is interpreted the same way.
HTTP response
HTTP/1.1 200 OK
Content-Type: application/json

{ "status": "published", "url": "https://example.com/blog/my-article", "id": "post_9081" }

The optional id field is the post’s identifier in your system; we store it and return it in later responses as delivery.external_id so that the two sides can be reconciled easily.

3xx responses are not followed and count as failures. Return a 4xx code for a permanent error (invalid content, for example) and 5xx for a transient one; in both cases the delivery is retried and, after the third failure, the “failed” state is recorded in the panel and in the article’s delivery object.

3.5. Re-delivery

After resolving a problem on your side, you can request the delivery of any article again in three ways: the “Resend to my site” button on the calendar in the panel, a call to POST /api/v1/articles/{id}/deliver, or a direct read through the endpoints of section 6. A re-delivery carries the same Idempotency-Key.

4. Delivery modes: draft and direct publish

The delivery mode is set per site and announced in every request through the mode field and the X-Mode header. The current value is shown in the panel (the Content API card) and in the response of GET /api/v1/me.

Draft — the default

Active for every site. Your service receives the article and cover and stores them as a draft; review and publication remain with you. This is the same behaviour we apply to WordPress customers on the Pro tier: the complete post, featured image included, lands in the dashboard automatically and you press “Publish”.

Direct publish — under a content-consent agreement

For customers who have signed the separate content-consent agreement with Vistaria, our team enables this mode for the site. Your endpoint is then expected to publish the article immediately and answer with published and the final URL — exactly as we publish our own articles, text and image, on the Vistaria blog. For WordPress sites the same agreement makes the post go live instead of being saved as a draft.

Enabling or withdrawing this mode is done only by the Vistaria team once the agreement is on file; it cannot be changed from the panel or the API. If your endpoint answers draft while in publish mode, the state is recorded as a draft and the publication has to be reported later.

Articles for which the sensitive-sector reviewer (medical, legal, financial and similar) has left a note are sent with the guard_note field and always with mode draft — even for a site in direct-publish mode. They must be reviewed by your specialist before publication; the same rule applies to the WordPress connector.

5. The cover image

A cover is produced for each article without text, in your brand’s visual language, and travels inline in the delivery; there is nothing to fetch from our servers. An article without a cover is delivered with cover = null. The cover object has the following fields:

FieldDescription
altSuggested alternative text, derived from the meta title.
primaryName of the primary variant (webp when available).
encodingAlways base64.
webp / jpgEach variant is an object with filename, content_type, bytes, url and data. data is the file content in standard base64 and url the public address of the same file on our server.

Recommendation: use the WebP variant as the page’s featured/cover image and the JPEG variant for og:image. Store the files in your own media library and do not hot-link to our address; our URLs exist for reconciliation and debugging, not for permanent hosting.

If a file exceeds 4 MB, the data field is omitted for that variant and only url is sent. On the single-article read endpoint, the ?cover=inline parameter returns the same inline structure.

6. Read and confirmation endpoints

These endpoints exist for reconciliation, rebuilds and reporting publication; they do not replace automatic delivery. All of them require the API key.

GET /api/v1/meSite status, tier, this month’s article quota, delivery mode and endpoint state. For key tests and health monitoring.
GET /api/v1/articlesFinished articles, newest first (summary version, without the text). Filter with status = all / ready / published; page with limit (≤100) and offset. Each row includes the delivery state (delivery).
GET /api/v1/articles/{id}One full article: markdown, html (ready to insert), seo, cover, schema_jsonld, guard_note and delivery. With ?cover=inline, the cover object carries the inline structure of the delivery instead of URLs.
POST /api/v1/articles/{id}/publishedReport publication: send the final URL (and, optionally, the post id in your system). This record is the basis of the day-30 report and the effectiveness review; without it, publication is only inferred from the sitemap. Repeated calls are harmless.
POST /api/v1/articles/{id}/deliverRequest re-delivery of one article to the registered endpoint. A 202 response means it was queued. Without an endpoint, 409 no_endpoint is returned.
Shell
curl -sS -H "X-Api-Key: vs_…" "https://vistaria.app/api/v1/articles?status=ready&limit=20"

curl -sS -H "X-Api-Key: vs_…" "https://vistaria.app/api/v1/articles/123?cover=inline"

curl -sS -X POST -H "X-Api-Key: vs_…" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/blog/my-article", "id": "post_9081"}' \
  https://vistaria.app/api/v1/articles/123/published

curl -sS -X POST -H "X-Api-Key: vs_…" https://vistaria.app/api/v1/articles/123/deliver

7. The article object

Dates are ISO-8601 in UTC. Fields marked star are only present in the full version (deliveries and single-article reads).

FieldTypeDescription
idintegerThe article’s id in Vistaria; used in every call.
titlestringThe article title (H1).
statusstringready until publication is recorded; then published.
slugstringSuggested URL slug (Latin, no spaces).
languagestringContent language (two-letter code); the language chosen for the site.
cycleintegerNumber of the 30-day plan cycle.
planned_fordateThe date this topic is scheduled for in the calendar.
published_urlstring | nullThe final URL once publication is recorded; otherwise null.
published_atdatetime | nullWhen publication was recorded.
coverobjectIn the summary version: webp and jpg URLs. In deliveries and with ?cover=inline: the structure of section 5.
deliveryobjectState of the delivery to your endpoint: status (queued / accepted / draft / published / failed), delivered_at, external_id and last_error.
urlstringThe address of this article in the API (full version).
seoobjectFull version only meta_title, meta_description, keywords, category, reading_min, tldr, internal_links. internal_links lists the suggested internal links used in the text.
markdownstringFull version only The full article in Markdown (the source of truth).
htmlstringFull version only The same text converted to semantic HTML (h2/h3, lists, tables, links), ready to insert into the page body.
schema_jsonldstringFull version only The JSON-LD string (BlogPosting and, where present, FAQPage) for the <head> inside <script type="application/ld+json">.
guard_notestringFull version only The sensitive-sector reviewer’s note, if any; otherwise an empty string.

The text is yours and you may adapt the HTML to your site’s template. We recommend keeping the heading structure, the internal links and the JSON-LD; the SEO results rest on them.

8. Error codes

On error, ok is false and the error field carries one of the codes below. The HTTP status is set accordingly.

CodeDescription and suggested action
401 unauthorizedThe key is missing, invalid or revoked, or the site’s tier has no article quota. Check the key and the subscription state in the panel.
400 bad_*Invalid input: a publication URL that is not http(s) (bad_url), an invalid status value (bad_status) or malformed paging (bad_paging).
404 not_foundNo article with this id exists for this site, or it has not been written yet.
409 no_endpointNo delivery endpoint is registered for this site; register one in the panel first.
429 rate_limitedRate limit. The retry_after field gives the number of seconds to wait.
500 delivery_failedInternal error while queuing the delivery; retry in a few minutes and contact support if it persists.

9. Reference implementations

The following is a receiving endpoint in Node.js (Express) that verifies the signature and timestamp, detects duplicates through Idempotency-Key, stores the cover and answers according to the requested mode. Replace storeArticle() and publishArticle() with your CMS.

receiver.ts
// receiver.ts — Node 18+, Express
import express from "express";
import crypto from "node:crypto";
import { writeFile } from "node:fs/promises";

const SECRET = process.env.VISTARIA_WEBHOOK_SECRET!;   // whsec_…
const app = express();

app.post("/hooks/vistaria", express.raw({ type: "*/*", limit: "12mb" }), async (req, res) => {
  // 1) signature over the RAW body, constant-time compare
  const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const given = String(req.get("X-Signature") || "");
  if (given.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return res.sendStatus(401);

  // 2) replay window (5 min) and idempotency
  const sentAt = Date.parse(String(req.get("X-Timestamp") || ""));
  if (!sentAt || Math.abs(Date.now() - sentAt) > 5 * 60 * 1000) return res.sendStatus(401);
  const idem = String(req.get("Idempotency-Key") || "");
  const seen = await db.deliveries.findOne({ idem });
  if (seen) return res.json(seen.response);          // same answer as before

  // 3) the payload: article + cover, nothing to fetch
  const { mode, article, cover } = JSON.parse(req.body.toString("utf8"));
  let coverPath: string | null = null;
  if (cover?.webp?.data) {
    coverPath = `/var/www/media/${cover.webp.filename}`;
    await writeFile(coverPath, Buffer.from(cover.webp.data, "base64"));
  }

  // 4) store; publish only when asked to
  const post = await storeArticle({
    externalId: article.id, title: article.title, slug: article.slug,
    html: article.html, markdown: article.markdown,
    metaTitle: article.seo?.meta_title, metaDescription: article.seo?.meta_description,
    jsonLd: article.schema_jsonld, cover: coverPath, coverAlt: cover?.alt ?? "",
    status: mode === "publish" ? "published" : "draft",
  });
  const response = mode === "publish"
    ? { status: "published", url: post.publicUrl, id: post.id }
    : { status: "draft", url: post.editUrl, id: post.id };

  await db.deliveries.insertOne({ idem, deliveryId: req.get("X-Delivery-Id"), response, at: new Date() });
  res.status(200).json(response);
});

The same endpoint in Python (FastAPI):

receiver.py
# receiver.py — Python 3.11+, FastAPI
import base64, hmac, hashlib, json, os
from datetime import datetime, timezone, timedelta
from fastapi import FastAPI, Request, Response

SECRET = os.environ["VISTARIA_WEBHOOK_SECRET"].encode()
app = FastAPI()

@app.post("/hooks/vistaria")
async def receive(req: Request):
    raw = await req.body()
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, req.headers.get("X-Signature", "")):
        return Response(status_code=401)
    sent = datetime.fromisoformat(req.headers.get("X-Timestamp", "").replace("Z", "+00:00"))
    if abs(datetime.now(timezone.utc) - sent) > timedelta(minutes=5):
        return Response(status_code=401)
    idem = req.headers.get("Idempotency-Key", "")
    if (prev := await deliveries.get(idem)):
        return prev                                   # idempotent replay

    body = json.loads(raw)
    art, cover, mode = body["article"], body.get("cover"), body["mode"]
    cover_path = None
    if cover and cover.get("webp", {}).get("data"):
        cover_path = f"/var/www/media/{cover['webp']['filename']}"
        with open(cover_path, "wb") as fh:
            fh.write(base64.b64decode(cover["webp"]["data"]))

    post = await store_article(art, cover_path, publish=(mode == "publish"))
    resp = ({"status": "published", "url": post.public_url, "id": post.id} if mode == "publish"
            else {"status": "draft", "url": post.edit_url, "id": post.id})
    await deliveries.put(idem, resp)
    return resp

A reconciliation script: it lists the finished articles whose publication has not been recorded, requests re-delivery where needed and, once published, reports the final URL.

reconcile.ts
// reconcile.ts — run daily, or after an outage on your side
const BASE = "https://vistaria.app";
const h = { "X-Api-Key": process.env.VISTARIA_API_KEY!, "Content-Type": "application/json" };

const { articles } = await fetch(`${BASE}/api/v1/articles?status=ready`, { headers: h }).then(r => r.json());
for (const a of articles) {
  const local = await findByExternalId(a.id);
  if (!local) {                                      // never arrived → ask for it again
    await fetch(`${BASE}/api/v1/articles/${a.id}/deliver`, { method: "POST", headers: h });
    continue;
  }
  if (local.publishedUrl) {                          // live on your site, not yet recorded with us
    await fetch(`${BASE}/api/v1/articles/${a.id}/published`, {
      method: "POST", headers: h, body: JSON.stringify({ url: local.publishedUrl, id: local.id }) });
  }
}

10. Security and operations

  • Keep the signing secret and the API key only in environment variables or your server’s secret store; never in client-side code or a git repository.
  • Verify the signature before any processing, over the raw bytes, with a constant-time comparison. Reject requests without a valid signature with 401.
  • A 5-minute window on X-Timestamp and storing Idempotency-Key in your database eliminate replays and duplicate posts.
  • The endpoint must answer within 30 seconds. If storing or building takes longer, queue the request, return accepted and report the publication later.
  • Requests are sent with User-Agent: Vistaria-ContentAPI/1.1; authenticity is established by the signature.
  • If the secret is exposed, register the endpoint again to obtain a new secret; if the key is exposed, use “Rotate key”. Both actions are immediate and do not interrupt subsequent deliveries.
  • In direct-publish mode, keep a full log of deliveries (X-Delivery-Id, time, response status); that log is the shared reference of both parties under the content-consent agreement.

11. Versioning and changes

This document describes version 1. Adding a field or header is a compatible change and may happen without notice; your implementation should ignore unknown fields. Removing or changing the meaning of fields will only happen in a new version (/api/v2) with prior notice.

Change log

  • 11 September 2026 — Automatic delivery: the body of the article.ready event now carries the full text, metadata and the inline cover (previously a notification only). Added: the mode field and header, the X-Timestamp, X-Delivery-Id and Idempotency-Key headers, the endpoint response contract, the delivery object on articles, POST /api/v1/articles/{id}/deliver, the ?cover=inline parameter, and direct-publish mode under a content-consent agreement.
  • 11 September 2026 — Version 1 released: per-site key, read endpoints, publication reporting, signed webhook.

12. Frequently asked questions

When is an article delivered?

Immediately after the text and cover are produced, usually within minutes. Each calendar day’s article is written automatically on that day; it can also be requested earlier from the panel or Telegram. The API itself does not generate anything.

What happens if our endpoint is unavailable?

Three attempts are made with increasing back-off. After that the article stays in the panel with the “delivery failed” state and you can retrieve it from the panel, through POST /api/v1/articles/{id}/deliver, or by a direct read.

May we change the HTML or the image?

Yes; the content is yours. We recommend keeping the headings, internal links and JSON-LD, and using the image at its delivered dimensions without added text.

Can we use both an endpoint and the read endpoints?

Yes. Automatic delivery is the primary path and reading is for reconciliation and rebuilds; both work with the same key and secret.

How do we enable direct-publish mode?

Contact support to set up the content-consent agreement. Once recorded, the mode shown in the panel and in GET /api/v1/me changes to publish and every subsequent delivery carries it.

What language is the article in?

The language chosen for the site, given in the language field. It can be changed from the site settings in the panel.

How do we test the endpoint?

Register the endpoint and re-deliver a finished article from the panel (calendar → “Resend to my site”) or through POST /api/v1/articles/{id}/deliver. In a test environment you may temporarily point the endpoint at an HTTPS request-capture service.

13. Support

For technical questions, write from the panel or contact Vistaria support. Please quote the site id, the article id and, where available, the X-Delivery-Id value so the request can be traced quickly.