Skip to content

API Documentation

Deliver HashTransit posts to your websites and apps with the read-only Content API. Authenticate with an API key, filter and paginate posts, and render them with correct SEO.

On this page

Overview#

HashTransit is a multi-tenant publishing platform. Authors and organizations write posts once; HashTransit delivers them to hashtransit.net (public posts) and to your sites through this API (public and private posts). The API is read-only, except for POST /v1/blogs and PUT /v1/blogs/:id, which let organization keys — and user keys whose owner has an author-family role — create and update unpublished posts. It returns JSON and every request is authenticated with an API key.

Base URL & versioning#

https://api.hashtransit.net/api
  • All endpoints below are relative to this base, e.g. GET https://api.hashtransit.net/api/v1/blogs. Always use HTTPS.
  • The current version is v1 (/api/v1/*). New fields and optional parameters may be added without a version bump — ignore fields you don't know.
  • Breaking changes are announced in the changelog. The 2026-09-26 release made API keys mandatory on v1 — see the migration guide.

Public vs private content#

Every post lives in a section, and each section is either public or private. A post is only returned when it is published and its publish date has passed — drafts, pending, rejected and archived posts are never returned.

Public postPrivate post
Sectionpublic sectionprivate section
On hashtransit.net, sitemap, RSS, llms.txtyesnever
Readable via the APIby any valid keyonly by keys of the post's organization (org key, or a user key of an active member)
urllink to the post on hashtransit.netnull

Use private sections for content that should appear only on your own site, and public sections for content you are happy to share with the HashTransit audience too.

Getting an API key#

There are two kinds of keys. Both can read content; organization keys and user keys whose owner has an author-family role can also create posts via POST /v1/blogs.

User keys#

  1. Register or log in.
  2. Open Profile → API Keys.
  3. Create a key with a name (1–60 characters, e.g. acme-website-prod) and an optional expiry (1–365 days).
  4. Copy it immediately. The key starts with htu_ and is shown only once — HashTransit stores only a hash. If you lose it, revoke it and create a new one.

You can have up to 5 active keys. The list shows each key's name, prefix, creation date, last-used date and expiry. Revoking a key stops it immediately. Keys also stop working when they expire or the account is deactivated.

Organization keys#

  1. Log in with an account that is an admin of the organization.
  2. Open My Organizations and select the organization.
  3. In the API Key section click Generate (or Regenerate). The key starts with ht_.

Each organization has one key. Regenerating replaces it and the old key stops working immediately. Organization keys do not expire; keys of inactive organizations cannot authenticate.

Which key should I use?#

User key (htu_)Organization key (ht_)
Created byany active registered userorganization admin
How manyup to 5 active per user1 per organization
Create postsyes, if the owner has an author-family role (posts as themselves)yes — always, in its own organization
Public posts (all authors/orgs)yesno — only its own org's posts
Private postsof organizations where the owner is an active memberall published posts of that organization
Shown after creationonce onlyin My Organizations
Expiryoptional, 1–365 daysnone
Revocationrevoke individuallyregenerate
  • Organization site showing only its own content → organization key.
  • Aggregator or widget showing public HashTransit content, or experimenting → user key.
  • Org member who wants public content plus their org's private posts → user key.

Authentication#

Send your key in the X-API-Key header on every request:

curl -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  "https://api.hashtransit.net/api/v1/blogs?limit=5"
  • On /api/v1/* the header is the only accepted transport. A key in the query string (?apiKey=) is rejected with 401 api_key_in_query_not_allowed — query strings leak into logs, proxies, analytics and Referer headers.
  • Authorization: Bearer is reserved for hashtransit.net session tokens and is not accepted as an API key.
  • No key → 401 missing_api_key. Unknown, revoked or expired key → 401 invalid_api_key. A key in the query string (?apiKey= or ?api_key=) is rejected even if a valid header is also sent. Authentication runs before routing, so an unknown /v1 path returns 401 without a key.
  • Repeated failed key attempts from one IP (60 per minute) are throttled with 429.
  • The legacy /api/blogs/external endpoints still accept ?apiKey= but respond with Deprecation: true and a Warning header.

Call GET /v1/me to check which key you are using and what it can see.

Security best practices#

Your API key is a secret

Anyone who has your key can read everything it can read — including private posts. Never put it in browser JavaScript, a mobile or desktop app bundle, or a public repository.
  • Keep keys server-side: call the API from your backend, a serverless function, or at build time.
  • Use environment variables (e.g. HASHTRANSIT_API_KEY) or a secret manager; keep .env files out of version control.
  • One key per integration and environment so a leak can be revoked without breaking everything else. Set an expiry on keys used for experiments.
  • Rotate periodically and revoke immediately if a key may have leaked. Watch the last-used date on the API Keys page.
  • Only send keys over HTTPS.

Proxy pattern#

If a browser or mobile app needs HashTransit content, have it call your server, which adds the key and caches the response. Expose only the parameters your front end needs.

// server.js — Node 18+, Express. Your key never leaves the server.
const express = require('express');
const app = express();

const BASE = 'https://api.hashtransit.net/api/v1';
const TTL_MS = 10 * 60 * 1000; // cache for 10 minutes
let cache = { at: 0, body: null };

app.get('/api/news', async (req, res) => {
  try {
    if (!cache.body || Date.now() - cache.at > TTL_MS) {
      const r = await fetch(`${BASE}/blogs?limit=10&includeContent=false`, {
        headers: { 'X-API-Key': process.env.HASHTRANSIT_API_KEY },
      });
      if (!r.ok) return res.status(502).json({ error: 'Upstream error' });
      cache = { at: Date.now(), body: await r.json() };
    }
    res.set('Cache-Control', 'public, max-age=300');
    res.json(cache.body.blogs);
  } catch {
    res.status(502).json({ error: 'Upstream error' });
  }
});

app.listen(3000);

Endpoints#

All v1 endpoints require the X-API-Key header and return JSON with "success": true on success. Reads are GET; the write routes are POST /v1/blogs and PUT /v1/blogs/:id (organization keys, and user keys whose owner has an author-family role). API reads do not increment a post's viewCount.

EndpointPurpose
GET /v1/blogsLatest posts in your key's scope, with filters
GET /v1/blogs/:slugOne post by slug
POST /v1/blogsCreate a post (draft or pending approval) — org keys in their org, author-role user keys as themselves
PUT /v1/blogs/:idUpdate an unpublished post (draft or pending approval)
GET /v1/meDescribe the calling key
GET /v1/sectionsSections with at least one post you can read
GET /v1/categoriesActive categories
GET /v1/organizations/:id/blogsPosts of one organization

List blogs#

GET
/v1/blogs

Returns the posts your key can read, newest first by default. Every item is a Blog object.

Query parameters (all optional)

ParamTypeDefaultDescription
pageinteger ≥ 11Page number
limitinteger 1–10010Items per page; values above 100 are clamped to 100
sectionsection slug—Only posts in this section
categorycategory slug—Only posts in this category
localeen | es—Only posts in this language
authorusername—Only posts by this author
organizationinteger—Only this organization's posts. Org key: its own organization only. User key: all posts of organizations you belong to, or only the public posts of any other active organization. Unknown/inactive organization → 403 out_of_scope
qstring, 2–100 chars—Search title, summary and keywords (not full content)
fromISO-8601 date or date-time—Inclusive lower bound on publishedAt; a date means 00:00 UTC
toISO-8601 date or date-time—Inclusive upper bound on publishedAt; a date covers the whole UTC day
sortlatest | oldest | popularlatestpopular = most viewed first. Ties are broken by id (deterministic paging)
includeContenttrue | falsetruefalse omits content for lightweight lists

Example request

curl -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  "https://api.hashtransit.net/api/v1/blogs?section=technology&locale=en&limit=2"

Example response — 200 OK

{
  "success": true,
  "blogs": [
    {
      "id": 12,
      "title": "Five Ways Edge Caching Cuts Latency",
      "slug": "five-ways-edge-caching-cuts-latency",
      "summary": "A practical look at edge caching strategies for content-heavy sites.",
      "content": "<p>Edge caching moves content closer to readers…</p>",
      "keywords": "caching, cdn, performance",
      "coverImage": "https://www.hashtransit.net/uploads/blogs/edge-caching.jpg",
      "locale": "en",
      "publishedAt": "2026-09-01T12:00:00.000Z",
      "updatedAt": "2026-09-02T08:00:00.000Z",
      "viewCount": 120,
      "url": "https://www.hashtransit.net/blogs/five-ways-edge-caching-cuts-latency",
      "translations": [
        { "locale": "es", "slug": "cinco-formas-de-reducir-latencia-con-cache", "id": 13 }
      ],
      "author": { "username": "janedoe", "fullName": "Jane Doe", "avatar": "https://www.hashtransit.net/uploads/avatars/janedoe.jpg" },
      "section": { "name": "Technology", "slug": "technology" },
      "category": { "id": 1, "name": "Web Development", "nameEs": "Desarrollo Web", "slug": "web-development", "color": "#3b82f6" },
      "organization": { "id": 3, "name": "Acme Media" }
    }
  ],
  "pagination": { "totalItems": 42, "totalPages": 21, "currentPage": 1, "itemsPerPage": 2 },
  "filters": { "section": "technology", "locale": "en" }
}

filters echoes the filters that were applied.

Errors

400 invalid_parameter, 403 out_of_scope (with organization), 401 (missing/invalid key), 429 rate_limited.

Get blog#

GET
/v1/blogs/:slug

Returns one post, always including content.

Path paramDescription
slugThe post's slug, e.g. five-ways-edge-caching-cuts-latency

Example request

curl -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  "https://api.hashtransit.net/api/v1/blogs/five-ways-edge-caching-cuts-latency"

Example response — 200 OK

{
  "success": true,
  "blog": {
    "id": 12,
    "title": "Five Ways Edge Caching Cuts Latency",
    "slug": "five-ways-edge-caching-cuts-latency",
    "summary": "A practical look at edge caching strategies for content-heavy sites.",
    "content": "<p>Edge caching moves content closer to readers…</p>",
    "keywords": "caching, cdn, performance",
    "coverImage": "https://www.hashtransit.net/uploads/blogs/edge-caching.jpg",
    "locale": "en",
    "publishedAt": "2026-09-01T12:00:00.000Z",
    "updatedAt": "2026-09-02T08:00:00.000Z",
    "viewCount": 120,
    "url": "https://www.hashtransit.net/blogs/five-ways-edge-caching-cuts-latency",
    "translations": [
      { "locale": "es", "slug": "cinco-formas-de-reducir-latencia-con-cache", "id": 13 }
    ],
    "author": { "username": "janedoe", "fullName": "Jane Doe", "avatar": "https://www.hashtransit.net/uploads/avatars/janedoe.jpg" },
    "section": { "name": "Technology", "slug": "technology" },
    "category": { "id": 1, "name": "Web Development", "nameEs": "Desarrollo Web", "slug": "web-development", "color": "#3b82f6" },
    "organization": { "id": 3, "name": "Acme Media" }
  }
}

Errors

404 not_found — the post does not exist, is not published yet, or is outside your key's scope (the API does not reveal whether a private post exists). 401 (missing/invalid key), 429 rate_limited.

Create blog#

POST
/v1/blogs

Both key types can create — attribution differs

Organization keys (ht_…) can always create: organizationId is forced to the key's organization, and the optional authorId attributes the post to an active member of it. User keys (htu_…) can create only when their owner has an author-family role (admin, author, author-premium, author-staff, author-staff-og — otherwise 403 author_role_required) and always post as themselves. Every post is created as draft or pending_approval (default) — posts are never published through the API; publishing always goes through HashTransit's approval flow.

Creates a post. With an organization key the post is created in the key's own organization (organizationId is derived from the key); with a user key the author is the key owner and organizationId is optional. This lets organizations and authors publish into HashTransit from an external CMS, scheduler, or script using only an API key — no JWT login required.

Request fields

Send multipart/form-data (so a cover image file can be attached) or application/json (no file).

FieldRequiredTypeNotes
titleyesstringSanitized as plain text, max 255 chars
contentyesstring (HTML)Sanitized with the same allowlist as the site editor
sectionIdyesintegerMust reference an active section; otherwise 422 section_inactive
organizationIdno (user keys)integerUser keys only: an organization the key owner actively belongs to (else 403 out_of_scope); omit for a site-wide post. Organization keys cannot set it — it is forced to the key's organization.
statusnodraft | pending_approvalDefaults to pending_approval. Anything else (e.g. published) → 422 status_not_allowed — publishing always goes through HashTransit's approval flow.
summarynostringPlain text
keywordsnostringComma-separated, max 500 chars
categoryIdnointegerMust reference an existing category
localenoen | esDefaults to en
customSlugnostringSlugified; must produce a valid slug
authorIdnointegerOrganization key: if provided, must be an active member of the organization; if omitted, the post has no user author (the organization is the publisher of record). User key: the author is always the key owner — a different authorId → 422 author_mismatch.
coverImagenofileMultipart file upload — uploaded to our CDN (S3 or local /uploads/blogs) and auto-optimized server-side (resized to ≤2000px, recompressed ~q80, EXIF/GPS metadata stripped). Ignored for JSON requests.

Example request

curl -X POST "https://api.hashtransit.net/api/v1/blogs" \
  -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Quarterly report",
    "content": "<p>Q3 was a strong quarter...</p>",
    "sectionId": 5,
    "status": "pending_approval",
    "summary": "Q3 highlights",
    "keywords": "q3, report",
    "authorId": 12
  }'

Example response — 201 Created

{
  "success": true,
  "blog": {
    "id": 142,
    "slug": "quarterly-report",
    "title": "Quarterly report",
    "status": "pending_approval",
    "organizationId": 3,
    "sectionId": 5,
    "authorId": 12,
    "locale": "en",
    "summary": "Q3 highlights",
    "keywords": "q3, report",
    "coverImage": "https://cdn.hashtransit.net/uploads/blogs/cover-123.jpg",
    "createdAt": "2026-09-27T12:00:00.000Z"
  }
}

The created post appears in the pending-approval queue in the dashboard (unless created as a draft). Once approved, it is published and becomes readable through the GET endpoints (and on hashtransit.net if the section is public).

Errors

HTTPcodeCause
403out_of_scopeUser key: organizationId is not an organization the key owner actively belongs to
403author_role_requiredUser key whose owner has no author-family role (admin, author, author-premium, author-staff, author-staff-og)
400invalid_parameterMissing/invalid title, content, sectionId; invalid locale, categoryId, customSlug; authorId is not an active member of the org
404not_foundsectionId does not reference an existing section
422section_inactivesectionId references an inactive section
422status_not_allowedstatus is not draft or pending_approval — publishing always goes through the approval flow
422author_mismatchUser key: authorId does not match the key owner
401missing_api_keyNo X-API-Key header
401invalid_api_keyInvalid, revoked or expired key
429rate_limitedMore than 120 requests/minute on this key

Update blog#

PUT
/v1/blogs/:id

Updates an unpublished post. An organization key can update any post of its organization; a user key can update only posts it authored itself. Editing is allowed only while the post is draft or pending_approval — published, rejected or archived posts return 422 post_not_editable.

Path paramDescription
idNumeric post id, as returned by POST /v1/blogs

Request fields

Accepts the same fields as create — title, content, sectionId, summary, keywords, categoryId, locale, customSlug, status, authorId and coverImage — sent as multipart/form-data (so a cover image file can be attached) or application/json (no file). The slug regenerates from the title when the title changes, unless customSlug is provided.

Example request

curl -X PUT "https://api.hashtransit.net/api/v1/blogs/142" \
  -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Quarterly report — Q3 update",
    "summary": "Q3 highlights, revised",
    "status": "pending_approval"
  }'

Example response — 200 OK

{
  "success": true,
  "blog": {
    "id": 142,
    "slug": "quarterly-report-q3-update",
    "title": "Quarterly report — Q3 update",
    "status": "pending_approval",
    "organizationId": 3,
    "sectionId": 5,
    "authorId": 12,
    "locale": "en",
    "summary": "Q3 highlights, revised",
    "keywords": "q3, report",
    "coverImage": "https://cdn.hashtransit.net/uploads/blogs/cover-123.jpg",
    "createdAt": "2026-09-27T12:00:00.000Z",
    "updatedAt": "2026-10-01T09:30:00.000Z"
  }
}

Errors

HTTPcodeCause
404blog_not_foundNo post with this id
403out_of_scopeThe post is outside your key's scope — an org key can only update posts of its own organization, a user key only posts it authored
422post_not_editableThe post is not in draft or pending_approval status
401missing_api_keyNo X-API-Key header
401invalid_api_keyInvalid, revoked or expired key
429rate_limitedMore than 120 requests/minute on this key

Me#

GET
/v1/me

Describes the calling key: its type (user or organization), its owner, the organizations whose private posts it can read, whether it can create posts (canWrite), and its rate limit. Useful as a health check at deploy time.

curl -H "X-API-Key: $HASHTRANSIT_API_KEY" "https://api.hashtransit.net/api/v1/me"

Example response (user key, illustrative)

{
  "success": true,
  "key": {
    "type": "user",
    "prefix": "htu_3f9a1c2b",
    "owner": { "username": "janedoe", "role": "author" },
    "canWrite": true,
    "scope": {
      "publicPosts": true,
      "organizations": [{ "id": 3, "name": "Acme Media" }]
    },
    "rateLimit": { "limit": 120, "windowSeconds": 60 }
  }
}

For an organization key, type is "organization" and the key sees only its own organization's posts. canWrite is always true for organization keys; for user keys it is true when the owner has an author-family role (admin, author, author-premium, author-staff, author-staff-og), and user-key responses also include the owner's role. Field names other than type are illustrative.

Errors

401 (missing/invalid key), 429 rate_limited.

Sections#

GET
/v1/sections

Lists the sections that contain at least one post your key can read — handy for navigation. Use the slug as the section filter.

curl -H "X-API-Key: $HASHTRANSIT_API_KEY" "https://api.hashtransit.net/api/v1/sections"

Example response (illustrative)

{
  "success": true,
  "sections": [
    { "name": "Technology", "slug": "technology" },
    { "name": "Company News", "slug": "company-news" }
  ]
}

Errors

401 (missing/invalid key), 429 rate_limited.

Categories#

GET
/v1/categories

Lists all active categories (platform-wide). nameEs is the Spanish label and color a hex colour for badges. Use the slug as the category filter.

curl -H "X-API-Key: $HASHTRANSIT_API_KEY" "https://api.hashtransit.net/api/v1/categories"

Example response (illustrative)

{
  "success": true,
  "categories": [
    { "id": 1, "name": "Web Development", "nameEs": "Desarrollo Web", "slug": "web-development", "color": "#3b82f6" }
  ]
}

Errors

401 (missing/invalid key), 429 rate_limited.

Organization blogs#

GET
/v1/organizations/:id/blogs

Equivalent to /v1/blogs?organization=:id: same query parameters, same response shape.

curl -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  "https://api.hashtransit.net/api/v1/organizations/3/blogs?limit=5"

Errors

403 out_of_scope if the organization is not in your key's scope (an org key can only request its own organization), 400 invalid_parameter if id is not an integer, 401 (missing/invalid key), 429 rate_limited.

Filters#

Filters on /v1/blogs and /v1/organizations/:id/blogs combine with AND. They only narrow your key's scope, never widen it.

  • Invalid values are rejected with 400 invalid_parameter (e.g. locale=fr, sort=random, page=0, q=a). Nothing is silently ignored — except limit, which is clamped to 100.
  • section and category are slugs (from /v1/sections and /v1/categories); author is a username; organization is an integer id.
  • q searches title, summary and keywords (not the body), 2–100 characters. URL-encode it.
  • from / to are ISO-8601 dates or date-times forming an inclusive range on publishedAt.
  • includeContent=false drops content from every item — much smaller responses for lists and widgets.

Recipes#

GoalRequest
Latest 5 posts (lightweight)GET /v1/blogs?limit=5&includeContent=false
Spanish posts in one sectionGET /v1/blogs?section=technology&locale=es
Monthly archive (March 2026)GET /v1/blogs?from=2026-03-01T00:00:00Z&to=2026-03-31T23:59:59Z&sort=oldest&limit=100
SearchGET /v1/blogs?q=edge%20caching&limit=10
Most popular postsGET /v1/blogs?sort=popular&limit=5&includeContent=false
An author in a categoryGET /v1/blogs?author=janedoe&category=web-development

Recommended: list page + detail page

Render index pages from GET /v1/blogs?limit=20&includeContent=false (title, summary, cover, date) and article pages from GET /v1/blogs/:slug. List responses stay small and the two page types can be cached independently.

Pagination & sorting#

"pagination": { "totalItems": 42, "totalPages": 5, "currentPage": 1, "itemsPerPage": 10 }
  • Request pages with page (1-based) and limit (1–100, default 10). There are more pages while currentPage < totalPages.
  • Pagination is offset-based: posts published while you paginate shift items between pages. For full syncs, sort by oldest and/or use from/to windows.
sortOrder
latestnewest publishedAt first (default)
oldestoldest publishedAt first
popularhighest viewCount first

The Blog object#

Every endpoint that returns posts — including the legacy /api/blogs/external — uses the same shape.

FieldTypeDescription
idintegerPost id
titlestringTitle
slugstringURL-safe identifier
summarystring | nullShort summary — a good meta description
contentstring (HTML)Sanitized HTML. Omitted when includeContent=false
keywordsstring | nullComma-separated keywords
coverImagestring | nullCover image URL
locale"en" | "es"Language of the post
publishedAtISO-8601Publication date-time (UTC)
updatedAtISO-8601Last modification date-time (UTC)
viewCountintegerViews on hashtransit.net (API reads are not counted)
urlstring | nullCanonical URL on hashtransit.net only for public posts; null for private posts
translationsarrayOther-language versions inside your key's scope: { locale, slug, id }
authorobject{ username, fullName, avatar }
sectionobject{ name, slug }
categoryobject | null{ id, name, nameEs, slug, color }
organizationobject | null{ id, name }

Never included: author e-mail, id or role; moderation data; organization API keys or tracking configuration; internal foreign-key ids.

Errors#

Errors use one envelope with a machine-readable code. Branch on error.code, not on message.

{
  "success": false,
  "error": {
    "code": "missing_api_key",
    "message": "An API key is required. Send it in the X-API-Key header. See /api-docs#authentication."
  }
}
HTTPcodeMeaningWhat to do
400invalid_parameterA parameter is invalidFix the request; message names the parameter
401missing_api_keyNo X-API-Key headerSend the header
401invalid_api_keyUnknown, revoked or expired key, or deactivated ownerCheck or replace the key
401api_key_in_query_not_allowedKey sent as ?apiKey= on v1Move it to the header
403out_of_scopeOrganization not in your key's scopeUse a key with access
404not_foundPost does not exist or is not visible to your keyTreat as gone
429rate_limitedToo many requestsBack off and retry
500internal_errorUnexpected server error (no internals exposed)Retry later with backoff

Rate limits#

Requests with an API key (/api/v1/* and /api/blogs/external*) are limited to 120 requests per minute per key. Failed key attempts are separately limited to 60 per minute per IP. Every response includes standard headers:

HeaderMeaning
RateLimit-LimitRequests allowed in the current window
RateLimit-RemainingRequests left in the current window
RateLimit-ResetSeconds until the window resets

Exceeding the limit returns 429 with rate_limited. Wait Retry-After (if present) or RateLimit-Reset seconds, retry with exponential backoff and jitter, and — above all — cache.

async function fetchWithRetry(url, init, attempts = 4) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(url, init);
    if (res.status !== 429 && res.status < 500) return res;
    const header = res.headers.get('Retry-After') ?? res.headers.get('RateLimit-Reset');
    const base = header ? Number(header) * 1000 : 1000 * 2 ** i;
    await new Promise((r) => setTimeout(r, Math.min(base, 60_000) + Math.random() * 250));
  }
  throw new Error('HashTransit API unavailable after retries');
}

Caching#

  • Cache on your server: lists for 5–15 minutes, articles for 15–60 minutes.
  • Next.js: fetch(url, { next: { revalidate: 600 } }) or export const revalidate = 600 (ISR).
  • Other stacks: an in-memory or Redis cache keyed by URL, or a CDN in front of your proxy.
  • Static sites: fetch at build time and rebuild on a schedule.
  • Serve stale content if the API is temporarily unavailable rather than showing an error page.

Rendering content#

content is HTML that HashTransit sanitizes when posts are saved. Treat third-party HTML defensively anyway and sanitize again on your side — DOMPurify (isomorphic-dompurify on the server) for JavaScript, HTML Purifier for PHP, nh3 or bleach for Python. Always escape plain-text fields such as title and summary.

Images: coverImage and author.avatar are absolute URLs or null. Provide a fallback, reserve space to avoid layout shift, and use the title as alt when you have nothing better. With next/image, add the image host to images.remotePatterns. Before 2026-09-26 the legacy endpoint could return relative paths; if you stored any, prefix them with https://www.hashtransit.net.

Translations: translations lists sibling posts (en ↔ es) your key can read. Fetch them with GET /v1/blogs/:slug and link language versions with hreflang:

<link rel="alternate" hreflang="en" href="https://your-site.com/news/five-ways-edge-caching-cuts-latency" />
<link rel="alternate" hreflang="es" href="https://your-site.com/es/news/cinco-formas-de-reducir-latencia-con-cache" />

SEO for syndicated content#

When the same article is on hashtransit.net and your site, search engines see duplicate content and pick one URL to rank. Decide deliberately which one that is.

Public posts (url is not null)

The article also lives on hashtransit.net. Recommended: point your page's canonical at the HashTransit URL so ranking signals consolidate on one URL. If your site should rank instead, publish the content in a private section.
<link rel="canonical" href="https://www.hashtransit.net/blogs/five-ways-edge-caching-cuts-latency" />

Private posts (url is null)

The article exists only on your site(s): use your own URL as the canonical. If you syndicate the same private post to several of your sites, pick one primary site and point the others' canonicals at it.
  • Use summary as the meta description, title as the page title, coverImage as og:image.
  • Set the page language from locale and add hreflang links for translations.
  • Add BlogPosting structured data, with mainEntityOfPage equal to your canonical URL:
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Five Ways Edge Caching Cuts Latency",
  "description": "A practical look at edge caching strategies for content-heavy sites.",
  "image": "https://www.hashtransit.net/uploads/blogs/edge-caching.jpg",
  "datePublished": "2026-09-01T12:00:00.000Z",
  "dateModified": "2026-09-02T08:00:00.000Z",
  "inLanguage": "en",
  "author": { "@type": "Person", "name": "Jane Doe" },
  "publisher": { "@type": "Organization", "name": "Acme Media" },
  "mainEntityOfPage": "https://www.hashtransit.net/blogs/five-ways-edge-caching-cuts-latency"
}
</script>

Code examples#

All examples read the key from the HASHTRANSIT_API_KEY environment variable and run on the server. The JavaScript example needs Node 18+ (built-in fetch); Python needs requests.

export HASHTRANSIT_API_KEY="htu_your_key_here"

# Latest 5 posts, no body
curl -sS -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  "https://api.hashtransit.net/api/v1/blogs?limit=5&includeContent=false"

# One post
curl -sS -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  "https://api.hashtransit.net/api/v1/blogs/five-ways-edge-caching-cuts-latency"

# Show rate-limit headers
curl -sS -D - -o /dev/null -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  "https://api.hashtransit.net/api/v1/me"

Next.js (App Router)#

A server-rendered list and article page with ISR (10 minutes). The key never reaches the browser, and the article page sets its canonical from url.

const BASE = 'https://api.hashtransit.net/api/v1';

export async function ht<T>(path: string, revalidate = 600): Promise<T | null> {
  const res = await fetch(`${BASE}${path}`, {
    headers: { 'X-API-Key': process.env.HASHTRANSIT_API_KEY! },
    next: { revalidate },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`HashTransit API error ${res.status}`);
  return res.json() as Promise<T>;
}

Legacy external API#

GET /api/blogs/external and GET /api/blogs/external/:slug predate v1. They keep working, accept organization and user keys, and return the shared Blog object, but are frozen — new integrations should use v1.

ParamTypeDefaultDescription
pageinteger1Page number
limitinteger 1–10010Items per page (clamped to 100)
sectioninteger or slug—Section id or slug
localeen | es—Language filter (other values → 400)
curl -H "X-API-Key: $HASHTRANSIT_API_KEY" \
  "https://api.hashtransit.net/api/blogs/external?page=1&limit=5&locale=en"
{
  "organization": { "id": 3, "name": "Acme Media" },
  "blogs": [ /* Blog objects */ ],
  "pagination": { "totalItems": 25, "totalPages": 3, "currentPage": 1, "itemsPerPage": 10 }
}

Prefer the X-API-Key header. ?apiKey= still works here but is deprecated (responses carry Deprecation: true and a Warning header). The same rate limit applies.

Migration guide#

From anonymous /api/v1 (before 2026-09-26)#

  1. An API key is required on every /api/v1/* request, in the X-API-Key header. Anonymous calls get 401 missing_api_key.
  2. Pagination moved into a pagination object: top-level count, totalPages, currentPage → pagination.totalItems, pagination.totalPages, pagination.currentPage, pagination.itemsPerPage.
  3. Blog objects use the shared shape. Removed: author.id, section.id, organization.logo, createdAt. Added: keywords, locale, url, translations, category, organization, updatedAt.
  4. organizationId → organization (or /v1/organizations/:id/blogs).
  5. Invalid parameters return 400 invalid_parameter instead of being ignored.
  6. Errors use { success: false, error: { code, message } }.
  7. Private-section posts are only returned to keys of their organization.
  8. API reads no longer increment viewCount.
- const res = await fetch('https://api.hashtransit.net/api/v1/blogs?limit=10&organizationId=3');
- const { blogs, count, totalPages } = await res.json();
+ const res = await fetch('https://api.hashtransit.net/api/v1/blogs?limit=10&organization=3', {
+   headers: { 'X-API-Key': process.env.HASHTRANSIT_API_KEY },
+ });
+ const { blogs, pagination: { totalItems: count, totalPages } } = await res.json();

From /api/blogs/external?apiKey=#

  1. Move the key from the query string to the X-API-Key header.
  2. Consider switching to /api/v1/blogs: blogs and pagination have the same shape; the top-level organization is replaced by each post's own organization; you gain slug filters, search, date ranges, sorting and includeContent.
  3. Keep your key server-side.

Changelog#

2026-10-01#

  • New: POST /v1/blogs accepts user keys whose owner has an author-family role (they post as themselves; optional organizationId must be an organization they actively belong to), plus an optional status field (draft | pending_approval, default pending_approval — publishing still always goes through HashTransit's approval flow).
  • New: PUT /v1/blogs/:id — update an unpublished post (draft or pending_approval). Org keys update their organization's posts; user keys update their own posts.
  • New: GET /v1/me returns canWrite and, for user keys, the owner's role.
  • Changed: sectionId on POST /v1/blogs must reference an active section (422 section_inactive otherwise).

2026-09-27#

  • New: POST /v1/blogs — an organization API key can create a post in its own organization. Posts are always pending_approval (an org admin/staff must approve).organizationId is derived from the key. Optional authorId must be an active org member. Cover image is uploaded to our CDN via multipart.
  • Schema: Blog.authorId is now nullable (posts created via an org key without an authorId have no user author; the organization is the publisher of record).

2026-09-26#

  • Breaking: /api/v1/* requires an API key in the X-API-Key header; query-string keys are rejected.
  • Breaking: v1 list responses use a pagination object and the shared Blog object.
  • Breaking: errors use { success: false, error: { code, message } }.
  • New: personal user API keys (htu_) under Profile → API Keys.
  • New: GET /v1/me, GET /v1/sections, GET /v1/categories.
  • New: filters section, category, locale, author, organization, q, from, to, sort, includeContent.
  • New: rate limiting (120 requests/minute per key) with RateLimit-* headers.
  • Changed: private-section posts are only returned to keys of their organization and no longer appear on hashtransit.net, its sitemap or RSS.
  • Changed: post HTML is sanitized on save; API reads no longer increment viewCount.
  • Deprecated: ?apiKey= on /api/blogs/external*.

Need an API key?

Create a personal key under Profile → API Keys, or — as an organization admin — under My Organizations.