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#
- 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 post | Private post | |
|---|---|---|
| Section | public section | private section |
| On hashtransit.net, sitemap, RSS, llms.txt | yes | never |
| Readable via the API | by any valid key | only by keys of the post's organization (org key, or a user key of an active member) |
url | link to the post on hashtransit.net | null |
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#
- Register or log in.
- Open Profile → API Keys.
- Create a key with a name (1–60 characters, e.g.
acme-website-prod) and an optional expiry (1–365 days). - 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#
- Log in with an account that is an admin of the organization.
- Open My Organizations and select the organization.
- 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 by | any active registered user | organization admin |
| How many | up to 5 active per user | 1 per organization |
| Create posts | yes, if the owner has an author-family role (posts as themselves) | yes — always, in its own organization |
| Public posts (all authors/orgs) | yes | no — only its own org's posts |
| Private posts | of organizations where the owner is an active member | all published posts of that organization |
| Shown after creation | once only | in My Organizations |
| Expiry | optional, 1–365 days | none |
| Revocation | revoke individually | regenerate |
- 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:
- On
/api/v1/*the header is the only accepted transport. A key in the query string (?apiKey=) is rejected with401 api_key_in_query_not_allowed— query strings leak into logs, proxies, analytics andRefererheaders. Authorization: Beareris 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/v1path returns 401 without a key. - Repeated failed key attempts from one IP (60 per minute) are throttled with
429. - The legacy
/api/blogs/externalendpoints still accept?apiKey=but respond withDeprecation: trueand aWarningheader.
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
- 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.envfiles 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.
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.
| Endpoint | Purpose |
|---|---|
GET /v1/blogs | Latest posts in your key's scope, with filters |
GET /v1/blogs/:slug | One post by slug |
POST /v1/blogs | Create a post (draft or pending approval) — org keys in their org, author-role user keys as themselves |
PUT /v1/blogs/:id | Update an unpublished post (draft or pending approval) |
GET /v1/me | Describe the calling key |
GET /v1/sections | Sections with at least one post you can read |
GET /v1/categories | Active categories |
GET /v1/organizations/:id/blogs | Posts of one organization |
List blogs#
Returns the posts your key can read, newest first by default. Every item is a Blog object.
Query parameters (all optional)
| Param | Type | Default | Description |
|---|---|---|---|
page | integer ≥ 1 | 1 | Page number |
limit | integer 1–100 | 10 | Items per page; values above 100 are clamped to 100 |
section | section slug | — | Only posts in this section |
category | category slug | — | Only posts in this category |
locale | en | es | — | Only posts in this language |
author | username | — | Only posts by this author |
organization | integer | — | 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 |
q | string, 2–100 chars | — | Search title, summary and keywords (not full content) |
from | ISO-8601 date or date-time | — | Inclusive lower bound on publishedAt; a date means 00:00 UTC |
to | ISO-8601 date or date-time | — | Inclusive upper bound on publishedAt; a date covers the whole UTC day |
sort | latest | oldest | popular | latest | popular = most viewed first. Ties are broken by id (deterministic paging) |
includeContent | true | false | true | false omits content for lightweight lists |
Example request
Example response — 200 OK
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#
Returns one post, always including content.
| Path param | Description |
|---|---|
slug | The post's slug, e.g. five-ways-edge-caching-cuts-latency |
Example request
Example response — 200 OK
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#
Both key types can create — attribution differs
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).
| Field | Required | Type | Notes |
|---|---|---|---|
title | yes | string | Sanitized as plain text, max 255 chars |
content | yes | string (HTML) | Sanitized with the same allowlist as the site editor |
sectionId | yes | integer | Must reference an active section; otherwise 422 section_inactive |
organizationId | no (user keys) | integer | User 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. |
status | no | draft | pending_approval | Defaults to pending_approval. Anything else (e.g. published) → 422 status_not_allowed — publishing always goes through HashTransit's approval flow. |
summary | no | string | Plain text |
keywords | no | string | Comma-separated, max 500 chars |
categoryId | no | integer | Must reference an existing category |
locale | no | en | es | Defaults to en |
customSlug | no | string | Slugified; must produce a valid slug |
authorId | no | integer | Organization 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. |
coverImage | no | file | Multipart 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
Example response — 201 Created
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
| HTTP | code | Cause |
|---|---|---|
| 403 | out_of_scope | User key: organizationId is not an organization the key owner actively belongs to |
| 403 | author_role_required | User key whose owner has no author-family role (admin, author, author-premium, author-staff, author-staff-og) |
| 400 | invalid_parameter | Missing/invalid title, content, sectionId; invalid locale, categoryId, customSlug; authorId is not an active member of the org |
| 404 | not_found | sectionId does not reference an existing section |
| 422 | section_inactive | sectionId references an inactive section |
| 422 | status_not_allowed | status is not draft or pending_approval — publishing always goes through the approval flow |
| 422 | author_mismatch | User key: authorId does not match the key owner |
| 401 | missing_api_key | No X-API-Key header |
| 401 | invalid_api_key | Invalid, revoked or expired key |
| 429 | rate_limited | More than 120 requests/minute on this key |
Update blog#
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 param | Description |
|---|---|
id | Numeric 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
Example response — 200 OK
Errors
| HTTP | code | Cause |
|---|---|---|
| 404 | blog_not_found | No post with this id |
| 403 | out_of_scope | The 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 |
| 422 | post_not_editable | The post is not in draft or pending_approval status |
| 401 | missing_api_key | No X-API-Key header |
| 401 | invalid_api_key | Invalid, revoked or expired key |
| 429 | rate_limited | More than 120 requests/minute on this key |
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.
Example response (user key, illustrative)
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#
Lists the sections that contain at least one post your key can read — handy for navigation. Use the slug as the section filter.
Example response (illustrative)
Errors
401 (missing/invalid key), 429 rate_limited.
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.
Example response (illustrative)
Errors
401 (missing/invalid key), 429 rate_limited.
Organization blogs#
Equivalent to /v1/blogs?organization=:id: same query parameters, same response shape.
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 — exceptlimit, which is clamped to 100. sectionandcategoryare slugs (from/v1/sectionsand/v1/categories);authoris a username;organizationis an integer id.qsearches title, summary and keywords (not the body), 2–100 characters. URL-encode it.from/toare ISO-8601 dates or date-times forming an inclusive range onpublishedAt.includeContent=falsedropscontentfrom every item — much smaller responses for lists and widgets.
Recipes#
| Goal | Request |
|---|---|
| Latest 5 posts (lightweight) | GET /v1/blogs?limit=5&includeContent=false |
| Spanish posts in one section | GET /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 |
| Search | GET /v1/blogs?q=edge%20caching&limit=10 |
| Most popular posts | GET /v1/blogs?sort=popular&limit=5&includeContent=false |
| An author in a category | GET /v1/blogs?author=janedoe&category=web-development |
Recommended: list page + detail page
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#
- Request pages with
page(1-based) andlimit(1–100, default 10). There are more pages whilecurrentPage < totalPages. - Pagination is offset-based: posts published while you paginate shift items between pages. For full syncs, sort by
oldestand/or usefrom/towindows.
| sort | Order |
|---|---|
latest | newest publishedAt first (default) |
oldest | oldest publishedAt first |
popular | highest viewCount first |
The Blog object#
Every endpoint that returns posts — including the legacy /api/blogs/external — uses the same shape.
| Field | Type | Description |
|---|---|---|
id | integer | Post id |
title | string | Title |
slug | string | URL-safe identifier |
summary | string | null | Short summary — a good meta description |
content | string (HTML) | Sanitized HTML. Omitted when includeContent=false |
keywords | string | null | Comma-separated keywords |
coverImage | string | null | Cover image URL |
locale | "en" | "es" | Language of the post |
publishedAt | ISO-8601 | Publication date-time (UTC) |
updatedAt | ISO-8601 | Last modification date-time (UTC) |
viewCount | integer | Views on hashtransit.net (API reads are not counted) |
url | string | null | Canonical URL on hashtransit.net only for public posts; null for private posts |
translations | array | Other-language versions inside your key's scope: { locale, slug, id } |
author | object | { username, fullName, avatar } |
section | object | { name, slug } |
category | object | null | { id, name, nameEs, slug, color } |
organization | object | 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.
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_parameter | A parameter is invalid | Fix the request; message names the parameter |
| 401 | missing_api_key | No X-API-Key header | Send the header |
| 401 | invalid_api_key | Unknown, revoked or expired key, or deactivated owner | Check or replace the key |
| 401 | api_key_in_query_not_allowed | Key sent as ?apiKey= on v1 | Move it to the header |
| 403 | out_of_scope | Organization not in your key's scope | Use a key with access |
| 404 | not_found | Post does not exist or is not visible to your key | Treat as gone |
| 429 | rate_limited | Too many requests | Back off and retry |
| 500 | internal_error | Unexpected 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:
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed in the current window |
RateLimit-Remaining | Requests left in the current window |
RateLimit-Reset | Seconds 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.
Caching#
- Cache on your server: lists for 5–15 minutes, articles for 15–60 minutes.
- Next.js:
fetch(url, { next: { revalidate: 600 } })orexport 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:
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)
Private posts (url is null)
- Use
summaryas the meta description,titleas the page title,coverImageasog:image. - Set the page language from
localeand addhreflanglinks for translations. - Add
BlogPostingstructured data, withmainEntityOfPageequal to your canonical URL:
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.
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.
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.
| Param | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer 1–100 | 10 | Items per page (clamped to 100) |
section | integer or slug | — | Section id or slug |
locale | en | es | — | Language filter (other values → 400) |
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)#
- An API key is required on every
/api/v1/*request, in theX-API-Keyheader. Anonymous calls get401 missing_api_key. - Pagination moved into a
paginationobject: top-levelcount,totalPages,currentPage→pagination.totalItems,pagination.totalPages,pagination.currentPage,pagination.itemsPerPage. - Blog objects use the shared shape. Removed:
author.id,section.id,organization.logo,createdAt. Added:keywords,locale,url,translations,category,organization,updatedAt. organizationId→organization(or/v1/organizations/:id/blogs).- Invalid parameters return
400 invalid_parameterinstead of being ignored. - Errors use
{ success: false, error: { code, message } }. - Private-section posts are only returned to keys of their organization.
- API reads no longer increment
viewCount.
From /api/blogs/external?apiKey=#
- Move the key from the query string to the
X-API-Keyheader. - Consider switching to
/api/v1/blogs:blogsandpaginationhave the same shape; the top-levelorganizationis replaced by each post's ownorganization; you gain slug filters, search, date ranges, sorting andincludeContent. - Keep your key server-side.
Changelog#
2026-10-01#
- New:
POST /v1/blogsaccepts user keys whose owner has an author-family role (they post as themselves; optionalorganizationIdmust be an organization they actively belong to), plus an optionalstatusfield (draft|pending_approval, defaultpending_approval— publishing still always goes through HashTransit's approval flow). - New:
PUT /v1/blogs/:id— update an unpublished post (draft orpending_approval). Org keys update their organization's posts; user keys update their own posts. - New:
GET /v1/mereturnscanWriteand, for user keys, the owner'srole. - Changed:
sectionIdonPOST /v1/blogsmust reference an active section (422 section_inactiveotherwise).
2026-09-27#
- New:
POST /v1/blogs— an organization API key can create a post in its own organization. Posts are alwayspending_approval(an org admin/staff must approve).organizationIdis derived from the key. OptionalauthorIdmust be an active org member. Cover image is uploaded to our CDN via multipart. - Schema:
Blog.authorIdis now nullable (posts created via an org key without anauthorIdhave no user author; the organization is the publisher of record).
2026-09-26#
- Breaking:
/api/v1/*requires an API key in theX-API-Keyheader; query-string keys are rejected. - Breaking: v1 list responses use a
paginationobject 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?
