HonestHook

Free API key (1,000/mo)Sign in

Home · API

API documentation

One API for public social data. One key, one response shape — profiles on 11 platforms, and posts on Threads, Bluesky and Mastodon. Rate limits, retries and platform changes are on us. Clean JSON, ready for your app or your AI agent.

We read the exact field each platform publishes, and return null when none exists — never an invented number. There is also a historical trend archive: what was trending, which nobody can reconstruct after the fact.

Try it first, without a key

One endpoint answers with no key and no account. Paste it into a terminal before you read anything else:

curl https://honesthook.com/api/v1/nichos

A real response, measured on 7 September 2026:

{"niches":[
  {"id":"ai-agents","label":"AI agents",
   "windows_24h":24,"last_window":"2026-09-07T18:00:00+00:00"},
  {"id":"devtools","label":"Developer tools",
   "windows_24h":24,"last_window":"2026-09-07T18:00:00+00:00"},
  {"id":"indie-saas","label":"Indie SaaS",
   "windows_24h":23,"last_window":"2026-09-07T18:00:00+00:00"}
]}

That is discovery metadata, not the archive: which niches exist, and how many hourly captures each one already has. It tells you the archive is real and how far back it goes — it does not return a line of it. Reading what is in the archive needs a key, and that is the next section.

Get a key

Free while the archive is young: 1,000 free credits per month — no card, no sales call. The allowance renews on the 1st; credits you buy do not expire.

Credits are the unit, not calls, and endpoints cost different amounts. The monthly allowance buys 1,000 calls of archive/trends (1 credit each) or 100 of archive/movers (10 each) — or any mix. archive/coverage costs nothing.

or get a key with just your email

Free, no card. One key per address — we store your email to attach the key to it and to warn you if something breaks.

Lost your key?

We cannot recover your old key — the database only keeps its hash. The link issues a new one and stops the old one from working. Your credits, your usage this month and your subscription stay as they are.

In a hurry? The quickstart is one screen: the key, a command with your key already in it, and the response you should get back.

Prefer the terminal? The signup is an endpoint too — no browser required:

curl -X POST https://honesthook.com/api/v1/chaves \
  -H "Content-Type: application/json" \
  -d '{"email":"you@company.com"}'

The key is returned once and never again: the database stores only its SHA-256. If you lose it, write to contato@honesthook.com and we rotate it by hand. Automatic re-issue would let anyone knock out your key by typing your address.

Authentication

Send the key as a bearer token. Every request to the archive needs it; discovery does not.

curl https://honesthook.com/api/v1/trends/ai-agents \
  -H "Authorization: Bearer hk_live_..."

Endpoints

GET /api/v1/nichos — what exists, and since when

No key required. Returns the niches and how much history each one has — never a line of the archive itself. Today: AI agents, Developer tools, Indie SaaS.

GET /api/v1/trends/{niche} — the archive

Key required. Returns the history of one niche grouped by item, not by window. A window is how the archive stores; an item with a series is how the question gets asked.

ParameterMeaning
fromISO 8601. Defaults to 7 days ago. Capped at 90 days back.
toISO 8601. Defaults to now.
limitA hint. The hard cap is 5,000 series points and lives in the database — a larger number here does not raise it.

GET /api/v1/movers/{niche} — what gained traction

Key required. /trends answers what is in the ranking. This one answers what gained traction since yesterday — a question you can only answer if you kept yesterday. It costs 10 credits, and only charges if it finds movement.

curl "https://honesthook.com/api/v1/movers/ai-agents?hours=24&limit=20" \
  -H "Authorization: Bearer hk_live_..."
ParameterMeaning
hoursHow far back to look for the window to compare against. 1 to 720, defaults to 24. A target, not a promise — read interval_min in the response for what you actually got.
limitTop N by points gained. 1 to 100, defaults to 20. The clamp lives in the database.

With movement:

{
  "niche": "ai-agents",
  "total": 2,
  "interval_min": 1208,
  "credits_charged": 10,
  "credits_balance": 990,
  "items": [
    {
      "external_id": "49569136",
      "title": "Show HN: Sageling - a local AI agent for Mac",
      "url": "https://sageling.ai/",
      "points": 7,
      "points_gained": 1,
      "comments": 1,
      "position": 2
    }
  ]
}

Without movement — still a 200, and free:

{
  "niche": "ai-agents",
  "items": [],
  "total": 0,
  "interval_min": null,
  "credits_charged": 0,
  "credits_balance": 1000,
  "note": "nothing gained traction in this window -- not charged"
}

Both are successes. Branch on total, not on the status code. “I looked and there was nothing” is a legitimate answer, and you should not have to pay for it — which is exactly why you should not build logic to avoid calling.

GET /api/v1/{platform}/posts — recent posts

Key required, 1 credit, and it does not charge when it finds nothing. Available on threads, bluesky and mastodon. Returns the posts the account itself wrote — reposts and boosts of other people are excluded by authorship, because they are not this account’s content.

curl "https://honesthook.com/api/v1/bluesky/posts?handle=bsky.app&limit=5" \
  -H "Authorization: Bearer hk_live_..."

Every item carries id, url, text, published_at and a metrics object with likes, replies, reposts and quotes.

PlatformlimitHow many you getAlways null
threadsNot acceptedWhatever the public page carried. Measured on three handles in September 2026: 4, 5 and 10. It is not a number you control.title, position, metrics.quotes
bluesky1–100, default 25Up to the limit, minus reposts of other accountstitle, position, thumbnail_url
mastodon1–40, default 20Up to the limit, minus booststitle, position, thumbnail_url

A limit outside the range is refused with 400, not silently clamped. The sources clamp in silence, and someone asking for 100 posts would receive 40 and conclude the account has 40.

Mastodon handles are always user@instance. The network is federated, so a bare name identifies nobody. Mastodon items also carry text_html alongside the cleaned text — that field is third-party HTML and must be sanitised before it reaches a page, or you have an XSS hole.

What this endpoint does not return

Threads reads posts from the same kind of document threads/profile reads — but it downloads that document again. Calling both for the same handle fetches twice. Measured 15 September 2026: posts right after profile took 1.55s with cached: false, against 1.60s for posts with no profile call before it. Identical, so nothing was reused. Caching by document instead of by endpoint would fix it, and has not been done.

POST /api/v1/chaves — issue a key

Takes {"email": "..."}, returns 201 with the key in plain text, once. One active key per address; a second request returns 409, without echoing anything about the key that already exists.

GET /api/v1/account — your own usage

Key required. Returns the balance and the spend of the key in the header, and no other. It costs 0 credits, always — checking a balance must never change it, or there would be no way to audit a charge without creating one.

curl "https://honesthook.com/api/v1/account" \
  -H "Authorization: Bearer hk_live_..."
{
  "success": true,
  "endpoint": "account",
  "data": {
    "key_prefix": "hk_live_f2e3febc",
    "key_created_at": "2026-09-15T14:15:47.683Z",
    "cycle_started_at": "2026-09-01T00:00:00.000Z",
    "credits_spent_this_cycle": 1,
    "credits_spent_by_endpoint": { "github/profile": 1 }
  },
  "credits_used": 0,
  "credits_remaining": 999,
  "credits_purchased_remaining": 0,
  "free_credits_remaining": 999,
  "free_credits_total": 1000,
  "free_credits_reset_at": "2026-10-01T00:00:00.000Z"
}

The cycle is the calendar month in UTC — the same window the charging code uses, so cycle_started_at and free_credits_reset_at cannot drift from what you are actually billed for.

credits_spent_by_endpoint only lists endpoints that charged. A call that cost nothing is not spend, and a 0 in that map would suggest a charge that never happened. A key that has not spent yet gets {}, with 200 — that is an answer, not an error.

The response carries the key prefix, never the key. The prefix is the same 16 characters stored in plain text on our side; it authorises nothing on its own.

What each call costs

Credits are debited inside the same database transaction that reads the archive — not by this route, and not afterwards. Some endpoints charge nothing when they find nothing; the table says which.

EndpointCreditsCharges on an empty result?
account0Free — checking a balance must not change it
archive/coverage0Free — it is discovery, not product
archive/trends1Yes
archive/movers10No

Every paid response carries credits_charged (what this call actually cost) and credits_balance (what is left). Read credits_charged rather than assuming the list price was charged — on an empty movers call it is 0.

How much you have left

The profile endpoints (/api/v1/<platform>/profile) answer with five credit fields, because there are two wallets and they behave differently.

"credits_used": 1,
"credits_remaining": 999,             // what you can spend right now
"credits_purchased_remaining": 0,     // bought credits — never expire
"free_credits_remaining": 999,        // this month's allowance
"free_credits_total": 1000,
"free_credits_reset_at": "2026-10-01T00:00:00.000Z"

credits_remaining is the only one that answers “can I call again” — it is the sum. The two parcels below it exist because the allowance resets on the 1st and purchased credits do not, so a single number would drop on its own at the turn of the month. free_credits_reset_at says exactly when, in UTC.

A field we could not compute comes back null, never 0. Zero is a fact about your account; null is a fact about our reading of it.

What comes back

{
  "niche": "ai-agents",
  "from": "2026-08-30T00:00:00Z",
  "to":   "2026-09-06T16:00:00Z",
  "points": 412,
  "truncated": false,
  "credits_charged": 1,
  "paid_from": "free",
  "free_used": 3,
  "free_total": 1000,
  "credits_balance": 0,
  "items": [
    {
      "external_id": "44912730",
      "title": "...",
      "url": "https://...",
      "platform": "hackernews",
      "entered_at": "2026-09-05T14:00:00Z",
      "left_at":   "2026-09-06T15:00:00Z",
      "best_position": 3,
      "movement": 9,
      "series": [
        { "window": "2026-09-05T14:00:00Z", "position": 12, "points": 41 },
        { "window": "2026-09-05T15:00:00Z", "position": 3,  "points": 96 }
      ]
    }
  ]
}

movement is the oldest position minus the newest: positive means it climbed.

truncated: true means there was more — not that anything failed. Two things trigger it: the 5,000-point cap, and the limit you sent yourself. If you asked for limit=3 and got truncated: true with points: 3, that is the parameter doing its job — drop it and you get the whole window. Only when you did not set limit does truncated mean the cap, and only then does narrowing the interval help.

Which fields you actually get

Every profile response carries the same twelve keys, on every platform. A key never disappears because the platform does not have it — it comes back null. That is the whole point: your code reads the same shape everywhere, and a null means “we do not know”, never “zero”.

What follows is which of the five metric fields are actually populated, per platform. Measured 2026-09-22. always means every response; sometimes means there is a condition, spelled out below; never means we return null on purpose, also explained below.

Platformfollowersfollowingposts_countverifiedis_private
tiktokalwaysalwaysalwaysalwaysalways
instagramalwaysalwayssometimesalwaysalways
blueskyalwaysalwaysalwaysalwaysnever
soundcloudalwaysalwaysalwaysalwaysnever
mastodonalwaysalwaysalwaysneveralways
threadsalwaysneverneveralwaysalways
githubalwaysalwaysnevernevernever
mediumalwaysalwaysnevernevernever
pinterestalwaysnevernevernevernever

posts_count on Instagram: only under ~10,000 posts

You get a number for accounts with fewer than about ten thousand posts, and null above that. It is not intermittent and it is not a failure: for a given handle the answer is the same on every call. Ask for a small account and it is always there; ask for a very large one and it never is.

Why: the exact field Instagram once exposed comes back empty, so we read the count from the page description. Under the threshold it reads 4,930 Posts and we give you 4930. Above it, the page itself only says 60.4K Posts — and 60.4K is anything between 60,350 and 60,449. Returning 60400 would be inventing precision the source never gave us, so we return null. If you need an order of magnitude for large accounts, this field will not give it to you, and we would rather you know that than get a plausible wrong number.

Every other field on Instagram — followers, following, verified, is_private — is exact, on accounts of any size.

metrics.quotes is missing on threads/posts

Post endpoints return a metrics object, and it is not the same on all three. bluesky/posts and mastodon/posts give you likes, reposts, replies and quotes. threads/posts gives you the first three and no quotes key at all — the source does not expose a quote count.

This is the one place where a key is absent rather than null, so metrics.quotes reads as undefined on Threads and as a number on the other two. If you sum or compare that field across platforms, guard it.

And metrics itself is null, not {}, when a post carries no metrics at all — “this thing has no metrics” is different from “it has metrics and they are zero”.

Fields we could fill, and deliberately do not

Seven cells above say never while the platform does publish something nearby. We leave them null, and the reason is the same in every case: the nearby number answers a different question, and a filled field that means something else is worse than an empty one. A null you can see; a wrong number that looks right, you cannot.

The rest of the never cells are not decisions at all: the platform has no such concept. GitHub has no verified badge and no private account. Bluesky has no private account. SoundCloud has private tracks, not private accounts. Threads does not publish a following count or a post count on the profile. In those cells null means “this platform does not have this idea” — which is why it is null and not false.

Errors

Every failure carries a stable error code. Branch on the code, not on the prose.

HTTPerrorMeaning
400intervalo_invalidofrom or to is not ISO 8601.
400parametro_invalidomovers only: hours outside 1–720 or limit outside 1–100. Nothing is charged.
400email_invalidoSignup only.
401chave_ausenteNo Authorization header. Not emitted by movers, which reports a missing header as chave_invalida — the check runs in the database, and an absent key reaches it looking like a wrong one.
401chave_invalidaUnknown or deactivated key.
—nicho_invalidoReserved, and never returned today. No endpoint validates the niche name: an unknown one comes back as an empty success, not a 404. Do not write a branch for this code — list the real niches at /api/v1/nichos instead.
405metodo_invalidoThe archive is GET; signup is POST.
409ja_tem_chaveThat address already has an active key.
402sem_creditosBody carries credits_balance and needed.
402teto_estouradoMonthly credit ceiling reached. Body carries spent_this_month and cap.
503endpoint_indisponivelThat endpoint is switched off at the database. Nothing charged.
502indisponivelOurs, not yours. Retry.

402 and 429 mean opposite things. 429 says wait and retry — the monthly call quota renews. 402 says waiting will not help: top up credits or raise the ceiling. A client that treats 402 as a rate limit builds a backoff loop that can never succeed.

A 402 carries the address with it, so your own error handler can point a human at the right page without hard-coding a URL:

{
  "success": false,
  "error": {
    "type": "sem_creditos",
    "message": "no credits left on this key",
    "upgrade_url": "https://honesthook.com/login?next=%2Faccount"
  },
  "credits_used": 0
}

It goes to the sign-in page because buying is tied to an account and a verified key — the free tier never needs one. The field appears only on 402; every other error leaves it out.

Successful reads also carry X-RateLimit-Limit and X-RateLimit-Remaining, so you can watch the quota without spending a second call on it.

Serving the last reading when a platform blocks us

A 451 means the platform refused our addresses — the profile exists and your key is fine. These refusals come in windows: one platform has gone up to 7 hours without a fresh read, and it comes back on its own.

You can opt in to receiving the last stored reading instead of the error, with the standard RFC 5861 header:

curl -H "Authorization: Bearer $HH_KEY" \
  -H "Cache-Control: stale-if-error=86400" \
  "https://honesthook.com/api/v1/medium/profile?handle=markmanson"

Without the header nothing changes: you get the 451, as always. We will not hand you old data you did not ask for.

When it applies, the response is 200 and carries three extra fields: stale: true, as_of — the real capture time, never the time of your request — and stale_age_seconds.

Limits

LimitValueEnforced where
Calls1,000 / month, renewing on the 1stDatabase
Span per call90 daysDatabase
Series points per call5,000Database
Keys per email1 activeUnique index

All four are enforced inside Postgres, not in the HTTP layer. That is deliberate: the browser-side database key is public, so a limit living in the route would be a limit anyone could walk around.

What this data does not support yet

A trend archive that flatters itself is worthless, so here is the part most API docs leave out.

Position is weaker than points, and before 2026-09-06 15:20 UTC it is unreliable. Until that moment the ranking was ordered by score alone, and tied items landed in whatever order the upstream search returned — which varies between calls. Most items in a snapshot sit within three points of each other, so ties are the rule, not the exception. In one measured pair of windows, 19 of 29 items “changed position” without gaining or losing a single point. Ordering is deterministic from that timestamp on. For anything earlier, read points and treat position as decoration.

movers ranks by points gained, not by position — and that is a correction, not a preference. Measured on 2026-09-06 across 19 pairs of windows: position changed for 19 of 29 items without a single point changing. That was tie-breaking noise between items sitting on the same score, not movement. points_gained is Hacker News’s own counter, not a number we derive, so it cannot reshuffle on its own. The response still carries position, for reference — do not sort by it, and do not diff it across calls.

interval_min is the real gap, and it is usually not the one you asked for. The endpoint compares the newest window against the archived window whose real capture time is closest to hours ago — not “the window N slots back”, because windows are not equidistant. Observed intervals have ranged from 12 to 321 minutes. Ask hours=24 of an archive that is 20 hours deep and you get interval_min: 1208, not 1440. Divide by the number that comes back, never by the one you sent.

A misspelled niche is an empty success, not an error — on both archive endpoints. Nothing validates the niche name. On movers a niche that does not exist returns the same empty, uncharged 200 as a niche where nothing gained points. On trends it returns items: [] with points: 0 — and still spends a call from your monthly quota, because the counter increments before the archive is read. Check /api/v1/nichos rather than reading an empty result as a finding. This is the failure shape this project keeps catching in itself: no error, and an answer that looks plausible.

A window is rounded to the hour, and that erases the real interval. Two captures 12 minutes apart and two captures 96 minutes apart both look like “one window”. The rounding is what makes collection idempotent and it is staying — but a delta without an interval is a number without a unit, so read the timestamps in series rather than assuming an hour passed.

One source is live today: Hacker News. Reddit is written and waiting on credentials. This matters unevenly by niche — Hacker News barely discusses indie SaaS, so that niche is thin by coverage, not by frequency. Check the status page before building on a niche: it publishes the last capture and the last 24 hours per source, including the sources that are broken.

We archive aggregate trend, never people. No author, no body text, no profile, no photo — only title, metric and position. That is enough to answer where a topic is going, and it keeps the archive out of the personal-data question entirely. See privacy.

Machine-readable

The full spec is at /openapi.json (OpenAPI 3.1), and there is an /llms.txt for agents. If you are pointing a model at this API, hand it the spec rather than this page.

Questions the archive can already answer

Which topics in your niche climbed fastest this week; what was trending the day a competitor launched; whether a spike held or decayed within six hours.

← Back to the front page · Archive status · Terms · Privacy · contato@honesthook.com