Reference
Publishing API
The Hash-town publishing API: post a headline, a link and one or more hashtags. Upserts on external_id, so retries never double-post.
The shape of it
A Hash-town tag page is an index, not a mirror. Each entry carries a headline, a date, an optional one-line summary and an optional image, and every one of them links out to the story on your own site. Full article bodies are never copied here — your page stays the canonical, indexable home of the content, and the tag page exists to gather and point at it.
Many publishers write into the same tag. Each post is credited to whoever sent it, so a tag page reads as a shared index rather than one outlet's feed.
tag_urls. Render those on your own page for the story, as links back to the tag pages. Tag pages already link out to stories; the backlinks close the loop and are what make the tag pages worth crawling.Base URL and authentication
All endpoints live under https://hash-town.com/api/v1/. HTTPS only. Every endpoint except /api/v1/health takes a bearer token:
Authorization: Bearer $HASHTOWN_TOKEN
Your token carries read and write access to your posts. Treat it as a production credential and keep it server-side. A missing or invalid token returns 401. Request bodies are JSON and capped at 64 KB.
Idempotency
external_id is required, and it is the primary key from our side. Posting the same external_id twice updates the existing entry in place instead of creating a second one — so a retry after a timeout, a redelivered webhook, or a full re-sync of your back catalogue are all safe. Use something stable and meaningful from your own system. A first write answers 201 with "created": true; an update answers 200 with "created": false.
Endpoints
POST/api/v1/posts
Create or update a post and file it under one or more hashtags. Tags are created on first use — there is no separate call to make one.
Four fields are required. This is a complete, valid request:
{
"external_id": "story-1482-03",
"headline": "Toronto approves the fourplex bylaw",
"url": "https://example.com/stories/fourplex-bylaw",
"tags": ["Toronto"]
}
| Field | Type | Required | Notes |
|---|---|---|---|
external_id | string | yes | Your stable id. Max 200 chars. The upsert key. |
headline | string | yes | Max 300 chars. |
url | string | yes | Absolute link to the story on your site. This is the canonical home of the content. canonical_url is accepted as an alias. |
tags | string[] | yes | 1–25 hashtags. A leading # is optional. Duplicates after normalization are collapsed. |
summary | string | no | One line, max 500 chars. Shown under the headline and used as the entry's description. Omitted entirely if you don't send one. |
date | string | no | ISO 8601. 2026-08-20 is read as UTC midnight; 2026-08-20T13:05:00Z also works. Defaults to the time of the write. published_at is accepted as an alias. |
source | string|object | no | Who to credit, e.g. "The Example Post" or { "name": "…" }. Defaults to the hostname of url. |
image | object|string | no | { "url": "…", "alt": "…" }, or a bare URL string. Optional width and height are used as-is when supplied. |
video_url | string | no | Supplementary video for the story, shown as a secondary link. The canonical url stays the article. |
A fuller example:
curl -sS -X POST https://hash-town.com/api/v1/posts \
-H "Authorization: Bearer $HASHTOWN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"external_id": "story-1482-03",
"headline": "Toronto approves the fourplex bylaw",
"summary": "Council votes 21-4 to allow fourplexes citywide, ending a two-year fight.",
"url": "https://example.com/stories/fourplex-bylaw",
"date": "2026-08-20",
"source": "The Example Post",
"tags": ["Toronto", "#Housing", "FourplexBylaw"],
"image": {
"url": "https://cdn.example.com/1482/housing.jpg",
"alt": "Council chamber during the fourplex vote"
}
}'
201 Created:
{
"ok": true,
"created": true,
"post": {
"external_id": "story-1482-03",
"headline": "Toronto approves the fourplex bylaw",
"summary": "Council votes 21-4 to allow fourplexes citywide, ending a two-year fight.",
"url": "https://example.com/stories/fourplex-bylaw",
"date": "2026-08-20T00:00:00.000Z",
"source": "The Example Post",
"image": {
"url": "https://hash-town.com/img/hashtown/posts/9f2c….jpg",
"source_url": "https://cdn.example.com/1482/housing.jpg",
"alt": "Council chamber during the fourplex vote",
"width": 1600,
"height": 900,
"mirrored": true
},
"tags": [
{ "tag": "#Toronto", "slug": "toronto", "url": "https://hash-town.com/-/toronto", "type": "topic", "status": "public" },
{ "tag": "#Housing", "slug": "housing", "url": "https://hash-town.com/-/housing", "type": "topic", "status": "public" },
{ "tag": "#FourplexBylaw", "slug": "fourplexbylaw", "url": "https://hash-town.com/-/fourplexbylaw", "type": "topic", "status": "public" }
],
"tag_urls": [
"https://hash-town.com/-/toronto",
"https://hash-town.com/-/housing",
"https://hash-town.com/-/fourplexbylaw"
],
"created_at": "2026-08-20T14:02:11.884Z",
"updated_at": "2026-08-20T14:02:11.884Z"
}
}
Images are copied into our own storage on first sight and served from https://hash-town.com/img/…, so a moved or expired original doesn't blank the tag page. The copy is keyed by source URL: re-posting the same image costs nothing. If the fetch fails the original URL is used as-is, and the write still succeeds. Intrinsic width and height are read from the file so the markup can reserve the right space.
GET/api/v1/posts/{external_id}
Read a post back exactly as stored, including its tag list and tag_urls. 404 if unknown.
curl -sS https://hash-town.com/api/v1/posts/story-1482-03 \
-H "Authorization: Bearer $HASHTOWN_TOKEN"
PATCH/api/v1/posts/{external_id}
Change some fields and leave the rest alone. Only the keys present in the body are touched; everything else is carried over from the stored post. It accepts the same fields as POST.
PATCH the rest in when it arrives. The alternative, re-posting the whole record, works too, but it makes you resend fields that haven't changed.curl -sS -X PATCH https://hash-town.com/api/v1/posts/story-1482-03 \
-H "Authorization: Bearer $HASHTOWN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "video_url": "https://www.youtube.com/watch?v=..." }'
Sending tags replaces the tag set, exactly as it does on POST. Omitting it leaves the existing tags in place.
DELETE/api/v1/posts/{external_id}
Retract a post. It disappears from every tag page it was filed under. Tags themselves are left in place. 404 if unknown.
curl -sS -X DELETE https://hash-town.com/api/v1/posts/story-1482-03 \
-H "Authorization: Bearer $HASHTOWN_TOKEN"
GET/api/v1/tags
Every tag, with its post count and whether it currently clears the indexing threshold.
{
"ok": true,
"tags": [
{ "tag": "#Toronto", "slug": "toronto", "url": "https://hash-town.com/-/toronto",
"type": "topic", "status": "public", "post_count": 12, "indexed": true }
]
}
GET/api/v1/tags/{slug}
A single tag's metadata.
PATCH/api/v1/tags/{slug}
Set a tag's type, status, or the casing of its display_name. The tag must already exist — post to it first.
| Field | Values | Effect |
|---|---|---|
type | place · brand · product · topic · event | Picks the schema.org class the tag page publishes for what the tag is about: place → Place, brand → Brand, product → Product, topic → Thing, event → Event. Defaults to topic. |
status | official · purchasable · public · reserved | The tag's claim state. Defaults to public. |
display_name | string | Re-cases the tag for display (toronto → Toronto). Must be the same hashtag; only letter case may differ. The URL never changes. |
type or casing changes it for everyone posting to it, so set it to describe the tag itself, not your use of it.curl -sS -X PATCH https://hash-town.com/api/v1/tags/toronto \
-H "Authorization: Bearer $HASHTOWN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "type": "place", "display_name": "Toronto" }'
GET/api/v1/health
Unauthenticated liveness check. Returns { "ok": true }.
How tags are normalized
A tag has exactly one URL. Incoming tags are NFKC-folded, stripped of a leading #, lowercased, and reduced to letters, numbers and underscores. Hashtags contain no spaces or hyphens, so those characters are dropped rather than substituted.
| You send | Lives at | Displays as |
|---|---|---|
Toronto | https://hash-town.com/-/toronto | #Toronto |
#Housing | https://hash-town.com/-/housing | #Housing |
Fourplex Bylaw | https://hash-town.com/-/fourplexbylaw | #FourplexBylaw |
The first spelling anyone sends sets the display casing; PATCH changes it later. Any other spelling of the same tag — different case, a stray #, a trailing slash — 301s to the canonical URL, so no two addresses ever serve the same page.
When a tag page gets indexed
A tag page with fewer than 3 posts is served noindex,follow and left out of sitemap.xml. It is still a real page — reachable, crawlable, and it still passes link equity to your stories — it just doesn't enter the search index until it has enough on it to be worth landing on. Cross the threshold and it is indexed and submitted automatically. GET /api/v1/tags reports where each tag stands.
Concretely: filing a story under one throwaway tag costs nothing, but the tag won't earn search traffic until 3 stories share it. Tag consistently, and with tags other people are also using, and the good tags compound.
Errors
Errors are JSON: { "ok": false, "error": { "code", "message", "field" } }. The field is present when one specific input is at fault.
| Status | Codes | Meaning |
|---|---|---|
400 | missing_field, invalid_type, too_long, invalid_url, invalid_date, invalid_tag, too_many_tags, invalid_enum, invalid_json | The request needs fixing. Don't retry unchanged. |
401 | unauthorized | Missing or invalid bearer token. |
404 | not_found | No such post or tag. |
405 | method_not_allowed | Wrong verb for that path. |
500 | internal_error | Our fault. Safe to retry — the write is idempotent. |
