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.

Reciprocal linking. Every successful write returns 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.

Tags are replaced wholesale on update. If a post went up with three tags and you re-post it with two, the third tag drops that entry immediately.

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"]
}
FieldTypeRequiredNotes
external_idstringyesYour stable id. Max 200 chars. The upsert key.
headlinestringyesMax 300 chars.
urlstringyesAbsolute link to the story on your site. This is the canonical home of the content. canonical_url is accepted as an alias.
tagsstring[]yes1–25 hashtags. A leading # is optional. Duplicates after normalization are collapsed.
summarystringnoOne line, max 500 chars. Shown under the headline and used as the entry's description. Omitted entirely if you don't send one.
datestringnoISO 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.
sourcestring|objectnoWho to credit, e.g. "The Example Post" or { "name": "…" }. Defaults to the hostname of url.
imageobject|stringno{ "url": "…", "alt": "…" }, or a bare URL string. Optional width and height are used as-is when supplied.
video_urlstringnoSupplementary 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.

Use this when something lands late. If a story is published before its video, its image, or its final summary exists, post it as soon as you have the headline and link — then 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.

FieldValuesEffect
typeplace · brand · product · topic · eventPicks 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.
statusofficial · purchasable · public · reservedThe tag's claim state. Defaults to public.
display_namestringRe-cases the tag for display (toronto → Toronto). Must be the same hashtag; only letter case may differ. The URL never changes.
Tags are shared. Changing a tag's 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 sendLives atDisplays as
Torontohttps://hash-town.com/-/toronto#Toronto
#Housinghttps://hash-town.com/-/housing#Housing
Fourplex Bylawhttps://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.

StatusCodesMeaning
400missing_field, invalid_type, too_long, invalid_url, invalid_date, invalid_tag, too_many_tags, invalid_enum, invalid_jsonThe request needs fixing. Don't retry unchanged.
401unauthorizedMissing or invalid bearer token.
404not_foundNo such post or tag.
405method_not_allowedWrong verb for that path.
500internal_errorOur fault. Safe to retry — the write is idempotent.