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/nichosA 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
Lost your key?
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.
| Parameter | Meaning |
|---|---|
from | ISO 8601. Defaults to 7 days ago. Capped at 90 days back. |
to | ISO 8601. Defaults to now. |
limit | A 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_..."| Parameter | Meaning |
|---|---|
hours | How 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. |
limit | Top 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.
| Platform | limit | How many you get | Always null |
|---|---|---|---|
threads | Not accepted | Whatever 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 |
bluesky | 1–100, default 25 | Up to the limit, minus reposts of other accounts | title, position, thumbnail_url |
mastodon | 1–40, default 20 | Up to the limit, minus boosts | title, 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
- No comments or replies from other people. Only what this account published.
- No posts beyond what the public page serves.
has_moreis not one answer for all three.blueskycomputes it from the cursor the source returns, so it can betrue.mastodonisfalsetoday because we do not yet read theLinkheader that would tell us.threadsisnull: its posts arrive inside the profile document, there is nothing to page, and we do not know how many were left out. Anullis us saying we do not know — never “there is no more”. - No view counts, anywhere. None of the three sources publish one on the public surface, so there is no field for it. An absent metric is
null, never0. - Only metrics we actually measured. Threads has no quote count, so
metrics.quotesisnullthere — and Threadsreshare_countandmedia_typeare left out because they were seen in only part of the sample. Bluesky’sbookmarkCountis left out because only Bluesky has it, and a field that exists on one platform out of ten is not a shape you can code against.
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.
| Endpoint | Credits | Charges on an empty result? |
|---|---|---|
account | 0 | Free — checking a balance must not change it |
archive/coverage | 0 | Free — it is discovery, not product |
archive/trends | 1 | Yes |
archive/movers | 10 | No |
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.
| Platform | followers | following | posts_count | verified | is_private |
|---|---|---|---|---|---|
| tiktok | always | always | always | always | always |
| always | always | sometimes | always | always | |
| bluesky | always | always | always | always | never |
| soundcloud | always | always | always | always | never |
| mastodon | always | always | always | never | always |
| threads | always | never | never | always | always |
| github | always | always | never | never | never |
| medium | always | always | never | never | never |
| always | never | never | never | never |
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.
github.posts_count— GitHub publishespublic_repos. A repository is not a post. Filling this would make an archive record “posts went from 11 to 12” for a number that counts something else, and nobody would notice, because the field would be populated and plausible. Repository count belongs in a field of its own, which this API does not have yet.medium.posts_count— the number on a Medium profile is the count of stories loaded on the first screen; the page scrolls infinitely. Publishing it would say “this person wrote 10 pieces” about someone who wrote 300.medium.verified— the profile carriesverifications.isBookAuthor. It is the only field with “verif” in the name, and it says something else entirely: that the person published a book. Mapping it would make this API assert a badge Medium never granted.pinterest.verified— Pinterest has at least three different badges:domain_verified,is_verified_merchant, and averified_identityon boards. Domain confirmed, merchant approved, identity checked — picking one would publish a badge the platform did not give.nullsays “we do not know which”;falsewould be a claim.pinterest.followingandpinterest.posts_count— both numbers exist on the page, but so do the equivalent counters for boards, and the wrong one looks exactly like the right one. They will be filled the day there is an anchor clean enough to tell them apart.mastodon.verified— a Mastodon profile can carry a link with averified_at. That proves the person controls a domain, not that the platform confirmed who they are. Reading it asverified: truewould publish a badge Mastodon does not issue.
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.
| HTTP | error | Meaning |
|---|---|---|
| 400 | intervalo_invalido | from or to is not ISO 8601. |
| 400 | parametro_invalido | movers only: hours outside 1–720 or limit outside 1–100. Nothing is charged. |
| 400 | email_invalido | Signup only. |
| 401 | chave_ausente | No 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. |
| 401 | chave_invalida | Unknown or deactivated key. |
| — | nicho_invalido | Reserved, 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. |
| 405 | metodo_invalido | The archive is GET; signup is POST. |
| 409 | ja_tem_chave | That address already has an active key. |
| 402 | sem_creditos | Body carries credits_balance and needed. |
| 402 | teto_estourado | Monthly credit ceiling reached. Body carries spent_this_month and cap. |
| 503 | endpoint_indisponivel | That endpoint is switched off at the database. Nothing charged. |
| 502 | indisponivel | Ours, 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.
- It costs no credits.
credits_usedis0: you did not get what you asked for. - 48 hours is a hard ceiling, and it beats your request. We measured hourly drift in stored follower counts: at 48h the worst platform moves 1.90% at the 95th percentile — still the same number. At 7 days it passes 5% and stops being. Ask for less than 48h if you want less; asking for more gets you the
451. - The shape is smaller, and says so. We store metrics only —
followers,following,posts_count,verified,is_private. No name, bio, avatar or URL, ever.fields_availablelists which of the five we actually have for that handle, so anullis never ambiguous between “zero” and “we never stored it”. - No stored reading means no stale response. A handle we have never fetched gets the
451, even with the header. We do not turn absence into data.
Limits
| Limit | Value | Enforced where |
|---|---|---|
| Calls | 1,000 / month, renewing on the 1st | Database |
| Span per call | 90 days | Database |
| Series points per call | 5,000 | Database |
| Keys per email | 1 active | Unique 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