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 /postsandGET /posts/{id}. - Authenticate with
Authorization: Bearer <key>orx-api-key: <key>. Either works everywhere. - Filter with
website,status,language,limitandoffsetβ 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.
/api/v2/postsEvery 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.
| Parameter | Type | Notes |
|---|---|---|
website | string | One 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. |
status | string | Matched 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. |
language | string | The editorial line, as the code is stored: en, de, pt-br. Exact match. |
limit | integer | Rows 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. |
offset | integer | Rows 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"
/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.
| Parameter | Type | Notes |
|---|---|---|
id | path, required | A 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. |
website | string | Optional, 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.
datais always an array on the list endpoint. No filters matching is a200with an empty array, not a 404.totalis the number of posts matching the filters before paging, sooffset + limit < totalmeans there is another page.paginationappears 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.
| Field | Type | Notes |
|---|---|---|
id | string | Stable 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. |
status | string | Where the post is in the pipeline. One of draft, pending_approval, in_progress, published, unpublished, failed_review β see the status table. |
slug | string | The 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. |
title | string | The headline the article is published under. |
meta | object | The SERP metadata, written for the results page rather than lifted from the body: meta.title (the title tag) and meta.description (the description tag). |
searchIntent | string | What the searcher wants: informational, commercial or transactional. |
keywords | object | keywords.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. |
images | object | images.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. |
html | string | The 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. |
markdown | string | The article as source markdown β what you would edit and store. Empty string until the article has been written. |
language | string | The editorial line the post belongs to: en, de, fr-ca. |
website | string | The primary domain you connected, e.g. acme.io β not the blog domain, even for a post that is live on a blog. subdomain. |
publishedAt | string | ISO 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.
| Status | What it means |
|---|---|
draft | Planned and queued. The publisher writes it and puts it live on its scheduled date, with no further approval needed. |
pending_approval | Planned and held, because the site requires approval. Only you can release it; the publisher will not touch it in this state. |
in_progress | Claimed by the publisher mid-publish. A request can catch a post here, which is why the value is documented rather than hidden. |
published | Live on the blog, with publishedAt set. |
unpublished | You pulled it after it shipped. It leaves the queue and is not re-queued by itself β putting it back is a deliberate act. |
failed_review | Rejected 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.
| HTTP | Code | When |
|---|---|---|
| 401 | invalid_api_key | No key in the request, the key is wrong, or it was rotated since you copied it. |
| 400 | invalid_post_id | The path segment is neither a positive integer nor slug:<slug>. |
| 400 | invalid_slug | slug: was sent with nothing after it. |
| 404 | post_not_found | No 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. |
| 429 | rate_limited | More 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.
- The window is fixed, not sliding: the counter resets on the minute and is shared by every endpoint of the account, so a sync and a live search box draw on the same ceiling.
- Over the limit is a 429 with Retry-After in seconds. Wait that long, then continue β the count includes the requests that were refused, so a tight retry loop keeps failing.
- A full pass over 4,000 posts is 20 requests at limit=200, well inside one minute. If you are near the ceiling, you are asking per post β page instead.
- Reading a key you do not need anymore? Regenerate it. A leaked key that has been rotated cannot spend your requests or read your posts.
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 hardeneda 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.
- No writes of any kind: nothing can create, edit, publish, unpublish, move or discard a post. Publishing stays in the dashboard, where a person decides.
- No date-range filter. Posts arrive newest planned first, so a date window is a page-through β see the recipes below.
- No Search Console metrics and no planned publish date in the payload. Published timestamps are the only date on a post.
- No webhooks, no event stream, and no long-poll. It is a request-and-response API.
- No sandbox. There is one API and one key, and because it is read-only the worst a mistake returns is rows you did not need.
- No per-site keys, no scopes and no OAuth. One key per account, revocable by regenerating it.
- No caching layer in front of it: responses are built per request, so there are no ETags and no conditional requests to make.
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