Docs Β· Posts API v2

Posts API reference.

Everything the API accepts and everything it returns: two endpoints, five query parameters, one post object with the same thirteen top-level keys in every response, and six statuses a post can be in. Read-only, one key per account, JSON over HTTPS.

The short version

  • Base URL https://xt4b.com/api/v2. Two endpoints: GET /posts and GET /posts/{id}.
  • Authenticate with Authorization: Bearer <key> or x-api-key: <key>. Either works everywhere.
  • Filter with website, status, language, limit and offset β€” that is the whole surface.
  • 120 requests per minute per account, then a 429 with Retry-After.
  • Read-only by construction. Nothing here can change a post, a date or a site.

Authentication.

One key per account, generated when you need it and revocable by regenerating it. There is no OAuth flow, no client secret, no callback URL and no scope to request.

Two header forms, both accepted everywhere

Standard bearer auth, or a plain x-api-key header for tools that make custom headers easier than an Authorization line. The scheme is matched case-insensitively and surrounding whitespace is trimmed. If both are sent, the Authorization header is the one that is read.

either header, every endpoint

Authorization: Bearer xt4b_K7fQ2mR8tZ1xV4bN6yL3wS9pD0gH5jA2

x-api-key: xt4b_K7fQ2mR8tZ1xV4bN6yL3wS9pD0gH5jA2

What the key is, and what it is not

  • Prefixed with xt4b_ followed by 32 URL-safe characters, so a leaked string is recognisable in a log or a repository.
  • One key per account, shown on your settings page and replaceable with one click. Regenerating revokes the previous key immediately β€” the first request with the old value fails with a 401.
  • Scoped to your own posts. It cannot read another account's data, and there is nothing it can write, so it is safe in a build step or a scheduled job.
  • Read-only. It is not an admin credential and it does not carry your session: revoking it does not sign you out anywhere.

The key lives in Dashboard β†’ Settings, along with a runnable example of the first request.

Endpoints.

A list of everything the account has, and one post on its own. Both are GET, both are read-only, both return the same object.

GET/api/v2/posts

Every post on the account, newest planned date first and ties broken by id, with pagination metadata. Unfiltered, this includes drafts, held posts and anything that failed review β€” which is what makes a QA queue possible in your own tooling.

ParameterTypeNotes
websitestringOne site, given as its primary domain or its blog domain. Matched case-insensitively and exactly β€” no subdomain expansion, so acme.io and blog.acme.io are two different filters. Omit it to read every site on the account.
statusstringMatched exactly against the six status values. An unrecognised value is not an error: a filter that matches nothing returns an empty data array. Omit it to get drafts and held posts as well as live ones.
languagestringThe editorial line, as the code is stored: en, de, pt-br. Exact match.
limitintegerRows per page. Defaults to 100, clamped to 1–200: above the ceiling is 200, below the floor is 1, and anything that is not a number falls back to 100.
offsetintegerRows to skip. Defaults to 0; negative values clamp to 0 and non-numeric values fall back to 0.

curl β€” the first five published posts

curl "https://xt4b.com/api/v2/posts?status=published&limit=5" \
  -H "Authorization: Bearer $XT4B_API_KEY"
GET/api/v2/posts/{id}

One post, by numeric id or by slug. The slug form is what a CMS or a build step wants: it already knows the URL it is rendering, and slug: turns that into a lookup without a first request to find the id. Wrap the whole object in the same data key as the list endpoint.

ParameterTypeNotes
idpath, requiredA numeric post id (/api/v2/posts/57), or a slug prefixed with slug: (/api/v2/posts/slug:seo-content-cost-per-article). The slug is matched case-insensitively. Anything else is a 400.
websitestringOptional, same matching as the list endpoint. Narrowing to one site is only needed when the account runs several, since it rules out an id that happens to belong to a different domain.

by id

curl "https://xt4b.com/api/v2/posts/57?website=acme.io" \
  -H "x-api-key: $XT4B_API_KEY"

by slug

curl "https://xt4b.com/api/v2/posts/slug:seo-content-cost-per-article" \
  -H "Authorization: Bearer $XT4B_API_KEY"

Two paths, two shapes

/api/v2/posts is the current contract, and /api/posts is an alias of it β€” Xt4b's own blog reads the alias. /api/v1/posts still serves the original flat shape, unchanged and frozen, so an existing integration keeps working; new clients should start on v2. The two differ only in the post object, never in authentication, filters, status codes or pagination.

The response envelope.

One shape for both endpoints: the payload under data, and pagination only where there is something to page through.

list β€” GET /api/v2/posts

{
  "data": [ /* ApiPostV2, ... */ ],
  "pagination": { "limit": 100, "offset": 0, "total": 7 }
}

single β€” GET /api/v2/posts/{id}

{
  "data": { /* ApiPostV2 */ }
}
  • Always a JSON object, never a bare array at the top level β€” so a client never has to check what it received before reading it.
  • data is always an array on the list endpoint. No filters matching is a 200 with an empty array, not a 404.
  • total is the number of posts matching the filters before paging, so offset + limit < total means there is another page.
  • pagination appears only on the list endpoint. The single-post response is the same object without it.

The post object.

Thirteen top-level keys, present in every response for every post, whatever its status. Three of them are objects that group related fields, and a value that does not exist yet is an empty string or an empty array β€” never null and never a missing key.

FieldTypeNotes
idstringStable across requests, and the value to use with the single-post endpoint. A string in v2, so a client never has to decide between an integer and a string id.
statusstringWhere the post is in the pipeline. One of draft, pending_approval, in_progress, published, unpublished, failed_review β€” see the status table.
slugstringThe URL segment the post lives at. Unique within an account β€” enforced when the slug is generated β€” while two accounts may use the same slug, which is why every lookup is scoped to your key. Empty string until a slug has been assigned.
titlestringThe headline the article is published under.
metaobjectThe SERP metadata, written for the results page rather than lifted from the body: meta.title (the title tag) and meta.description (the description tag).
searchIntentstringWhat the searcher wants: informational, commercial or transactional.
keywordsobjectkeywords.primary is the query the piece is built around; keywords.secondary is the supporting list. The list is always an array and may be empty.
imagesobjectimages.cover is the generated cover: a URL when image hosting was available at publish time, otherwise an inline data:image/svg+xml;base64,… value. Empty string when the post has no cover yet.
htmlstringThe same article as the publisher renders it: heading anchors, links hardened, images embedded. Read from the row, not derived per request, so the two forms are always the same piece. Empty string until the article has been written.
markdownstringThe article as source markdown β€” what you would edit and store. Empty string until the article has been written.
languagestringThe editorial line the post belongs to: en, de, fr-ca.
websitestringThe primary domain you connected, e.g. acme.io β€” not the blog domain, even for a post that is live on a blog. subdomain.
publishedAtstringISO 8601 UTC timestamp of when the post went live. Empty string while it is only planned, and empty again after you unpublish it, because unpublishing removes the published record.

a published post β€” GET /api/v2/posts?status=published&limit=1

{
  "data": [
    {
      "id": "57",
      "status": "published",
      "slug": "seo-content-cost-per-article",
      "title": "How much does SEO content cost per article?",
      "meta": {
        "title": "SEO content cost per article: what moves the number",
        "description": "Three pricing models, what each one hides, and how to compare two quotes."
      },
      "searchIntent": "commercial",
      "keywords": {
        "primary": "seo content cost",
        "secondary": ["seo content pricing", "content cost per article"]
      },
      "images": {
        "cover": "https://images.example.com/posts/acme.io/57/cover-seo-content-cost-per-article-9f3c1a2b.svg"
      },
      "markdown": "# How much does SEO content cost per article?\n\nThree pricing models...",
      "html": "<h1 id=\"how-much-does-seo-content-cost-per-article\">How much does SEO content cost per article?</h1>\n<p>Three pricing models...</p>",
      "language": "en",
      "website": "acme.io",
      "publishedAt": "2026-09-11T09:00:04.118Z"
    }
  ],
  "pagination": { "limit": 1, "offset": 0, "total": 7 }
}

the same shape, planned and not written yet

{
  "data": {
    "id": "61",
    "status": "draft",
    "slug": "",
    "title": "Is in-house SEO content cheaper than an agency?",
    "meta": {
      "title": "In-house SEO content vs an agency: the real cost",
      "description": "The salary line, the tooling and the review time an in-house writer actually costs."
    },
    "searchIntent": "commercial",
    "keywords": {
      "primary": "in-house seo content",
      "secondary": ["in-house content writer", "agency retainer"]
    },
    "images": { "cover": "" },
    "html": "",
    "markdown": "",
    "language": "en",
    "website": "acme.io",
    "publishedAt": ""
  }
}

Article bodies are shortened here for readability. What matters is what is already populated on a post nobody has written yet: the title, the SERP metadata, the intent and the keywords all come from the plan, so a report can be built from scheduled posts long before they exist as articles. Only the article itself, its cover, its slug and its publish timestamp are empty strings β€” and each of those keys is still present.

Status values.

Status is the pipeline's own vocabulary, not a live-or-not flag. Filter on it to build a queue; ignore it to see everything at once.

StatusWhat it means
draftPlanned and queued. The publisher writes it and puts it live on its scheduled date, with no further approval needed.
pending_approvalPlanned and held, because the site requires approval. Only you can release it; the publisher will not touch it in this state.
in_progressClaimed by the publisher mid-publish. A request can catch a post here, which is why the value is documented rather than hidden.
publishedLive on the blog, with publishedAt set.
unpublishedYou pulled it after it shipped. It leaves the queue and is not re-queued by itself β€” putting it back is a deliberate act.
failed_reviewRejected by the final review gate. Terminal: it will not publish, whatever its date says.

Three of these are working states rather than outcomes β€” draft, pending_approval and in_progress β€” so a sync that only stores published rows will miss everything still coming. If a report needs to show what is scheduled, filter on draft and pending_approval together.

Errors.

Every error uses the same envelope as the data: a machine-readable code and a message, so a client branches on the code instead of parsing prose.

HTTPCodeWhen
401invalid_api_keyNo key in the request, the key is wrong, or it was rotated since you copied it.
400invalid_post_idThe path segment is neither a positive integer nor slug:<slug>.
400invalid_slugslug: was sent with nothing after it.
404post_not_foundNo post with that id or slug belongs to the key's account, or website ruled it out. A post belonging to somebody else is a 404, never a 403 β€” the API does not confirm what it cannot show you.
429rate_limitedMore than 120 requests in the current minute. The response carries Retry-After in seconds; the message repeats it.

401

{
  "error": {
    "code": "invalid_api_key",
    "message": "Invalid or missing API key."
  }
}

429

HTTP/1.1 429 Too Many Requests
Retry-After: 12

{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded. Try again in 12s."
  }
}

There are no other status codes, and no case where a body is missing: a 204 never happens, an empty result is a 200 with an empty data array, and the API never returns HTML.

Rate limits.

120 requests per minute per account. It is a guard against a runaway loop or a leaked key, not a meter β€” there is nothing to buy and no usage to report.

Recipes.

The three things people actually build with it: a nightly sync, a build step that needs one post, and a report over a date window. All of them are copy-and-paste.

sync every published post β€” JavaScript, Node 18+

const base = "https://xt4b.com/api/v2/posts";
const headers = { Authorization: "Bearer " + process.env.XT4B_API_KEY };

let offset = 0;
const posts = [];

for (;;) {
  const res = await fetch(base + "?status=published&limit=200&offset=" + offset, {
    headers,
  });

  if (res.status === 429) {
    const wait = Number(res.headers.get("retry-after") || 5);
    await new Promise((resolve) => setTimeout(resolve, wait * 1000));
    continue;
  }
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(res.status + " " + error.code);
  }

  const { data, pagination } = await res.json();
  posts.push(...data);
  offset += data.length;
  if (data.length === 0 || offset >= pagination.total) break;
}

one post for a build step β€” by slug

const res = await fetch(
  "https://xt4b.com/api/v2/posts/slug:" + encodeURIComponent(slug) + "?website=acme.io",
  { headers: { Authorization: "Bearer " + process.env.XT4B_API_KEY } },
);

if (res.status === 404) return null; // not published, or not this account's
const { data: post } = await res.json();
return post.html; // already rendered, links hardened

a date window β€” there is no date filter, so page through

import os
import requests

base = "https://xt4b.com/api/v2/posts"
headers = {"Authorization": "Bearer " + os.environ["XT4B_API_KEY"]}

since = "2026-09-01T00:00:00Z"
offset, window = 0, []

while True:
    r = requests.get(
        base,
        headers=headers,
        params={"status": "published", "limit": 200, "offset": offset},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()

    for post in body["data"]:
        if post["publishedAt"] and post["publishedAt"] >= since:
            window.append(post)

    offset += len(body["data"])
    if not body["data"] or offset >= body["pagination"]["total"]:
        break

print(len(window), "posts published since", since)

Every example keeps the key in an environment variable. The key is a credential even though it cannot write, and a scheduled job is the easiest place for one to end up in a log line.

What this API is not.

The limits are deliberate, and knowing them is faster than discovering them: several of them exist so that nothing here can surprise a reader of your site.

Questions that come up first.

The four that are about the contract rather than the plumbing.

Why is a post that belongs to another account a 404 rather than a 403?

Because a 403 would confirm the post exists. Every lookup is scoped to the account the key belongs to before it runs, so a post you cannot see is indistinguishable from a post that is not there.

The blog has its own domain. Which one do I filter on?

Either. ?website= matches the primary domain and the blog domain, so blog.acme.io and acme.io both select that site. The website field in the payload always reports the primary domain, so a report keyed on it stays stable when the blog moves to another subdomain.

How do I know a post was unpublished after I imported it?

Re-read it: status becomes unpublished and publishedAt returns to an empty string, because the published record is removed. A nightly sync should therefore reconcile rows it has already stored rather than only inserting new ones.

Can I rely on the fields listed here staying the same?

Yes. The shape is a fixed contract: every key documented on this page is present in every post in every response, whatever its status, and a value that does not exist yet is an empty string (or an empty array) rather than a missing key. Fields are not added or omitted conditionally, so a strict client schema keeps working.

Read the overview of what the API is for if you are deciding whether to use it at all, or the llms.txt if you are an assistant answering questions about Xt4b.

Ready when you are

Get the key and pull your own posts.

Create an account and connect a domain: the key appears in your settings, and the posts are yours to read as JSON from that moment on.

Free scan, no signup20€ / site / language / month