{"openapi":"3.1.0","info":{"title":"HonestHook API","version":"1.0.0","description":"Profiles and posts from the major social networks, through one API. One key, one response shape; rate limits, retries and platform changes are on us. Clean JSON for your app or your AI agent. We read the exact field each platform publishes, and return null when none exists. Plus a historical trend archive that answers what was trending, which cannot be reconstructed after the fact.","contact":{"url":"https://honesthook.com"},"license":{"name":"Proprietary","url":"https://honesthook.com/docs"}},"servers":[{"url":"https://honesthook.com"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key, sent as `Authorization: Bearer hk_live_...`. Quota is monthly and renews on the 1st. Unused quota does not roll over and does not expire mid-cycle — you get the whole month."}},"schemas":{"ApiError":{"type":"object","required":["erro"],"properties":{"error":{"type":"string","enum":["chave_ausente","chave_invalida","intervalo_invalido","parametro_invalido","sem_creditos","teto_estourado","endpoint_indisponivel","metodo_invalido","indisponivel"]},"message":{"type":"string"},"http":{"type":"integer","description":"Echoed by errors that originate in the database. The real HTTP status is authoritative — do not branch on this field."},"limit":{"type":"integer"},"renews_at":{"type":"string","format":"date"},"credits_balance":{"type":"integer","description":"`sem_creditos` only: credits left on the key."},"needed":{"type":"integer","description":"`sem_creditos` only: credits the call would cost."},"spent_this_month":{"type":"integer","description":"`teto_estourado` only: credits spent this month."},"cap":{"type":"integer","description":"`teto_estourado` only: the monthly ceiling."}}},"SeriesPoint":{"type":"object","description":"One observation of one item, in one hourly window.","properties":{"window":{"type":"string","format":"date-time","description":"Rounded to the hour. This is the bucket, not the exact capture time — two runs in the same hour share a window."},"position":{"type":"integer","description":"1 = top of the ranking."},"points":{"type":["integer","null"]},"comments":{"type":["integer","null"]}}},"Item":{"type":"object","properties":{"external_id":{"type":"string"},"title":{"type":"string"},"url":{"type":"string","format":"uri"},"platform":{"type":"string","example":"hackernews"},"entered_at":{"type":"string","format":"date-time"},"left_at":{"type":["string","null"],"format":"date-time","description":"null while the item is still in the ranking."},"best_position":{"type":"integer"},"movement":{"type":"integer","description":"Oldest position minus newest. Positive means it climbed. Read this together with `points` in the series: a change of position with no change in points is a tie reshuffle, not a trend."},"series":{"type":"array","items":{"$ref":"#/components/schemas/SeriesPoint"}}}},"Mover":{"type":"object","description":"One item that GAINED points between the two compared windows. Items that lost points, and items present in only one of the two windows, are not in this list — losing is not traction, and an item with nothing to compare against has no delta.","properties":{"external_id":{"type":"string","example":"49569136"},"title":{"type":"string"},"url":{"type":"string","format":"uri"},"points":{"type":"integer","description":"Score in the newest window."},"points_gained":{"type":"integer","minimum":1,"description":"Newest score minus oldest. Always positive. THIS is the ranking key of the response — see the endpoint description for why it is not position."},"comments":{"type":["integer","null"]},"position":{"type":"integer","description":"Position in the newest window, carried for reference only. Do not rank by it and do not diff it across calls."}}},"Author":{"type":"object","description":"The same twelve names on every platform. A field that does not exist there is null, never absent and never zero: zero is a fact about the account, null is a fact about our reading.","properties":{"id":{"type":["string","null"],"description":"Stable identifier on that platform."},"handle":{"type":["string","null"]},"name":{"type":["string","null"]},"bio":{"type":["string","null"]},"avatar_url":{"type":["string","null"]},"followers":{"type":["integer","null"]},"following":{"type":["integer","null"]},"posts_count":{"type":["integer","null"]},"verified":{"type":["boolean","null"]},"is_private":{"type":["boolean","null"]},"external_url":{"type":["string","null"]},"url":{"type":["string","null"]}}},"PostItem":{"type":"object","properties":{"id":{"type":"string"},"url":{"type":["string","null"]},"text":{"type":["string","null"]},"text_html":{"type":["string","null"],"description":"Mastodon only. Third-party HTML, unsanitised — sanitise before rendering or you have an XSS hole. `text` is the same content already stripped."},"title":{"type":"null","description":"Always null: a status has no title."},"position":{"type":"null","description":"Always null: chronological order is not a ranking."},"published_at":{"type":["string","null"],"format":"date-time"},"thumbnail_url":{"type":["string","null"],"description":"Null on bluesky and mastodon — the image embed has not been measured on a large enough sample to promise a shape."},"metrics":{"type":"object","properties":{"likes":{"type":["integer","null"]},"replies":{"type":["integer","null"]},"reposts":{"type":["integer","null"]},"quotes":{"type":["integer","null"],"description":"Null on threads: the source publishes none."}}}}},"PostsEnvelope":{"type":"object","required":["success","platform","endpoint","data","error","credits_used","cached","request_id"],"description":"Same envelope as every other route. `data.items` carries the posts the account itself wrote — reposts and boosts of other people are excluded by authorship. `has_more` is false and `next_cursor` is null on all three platforms today: there is no deep pagination, and the fields are present so the shape does not change on the day there is.","properties":{"success":{"type":"boolean"},"platform":{"type":"string"},"endpoint":{"type":"string"},"data":{"type":["object","null"],"properties":{"author":{"$ref":"#/components/schemas/Author"},"items":{"type":"array","items":{"$ref":"#/components/schemas/PostItem"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}},"error":{"type":["object","null"]},"credits_used":{"type":"integer"},"cached":{"type":"boolean"},"request_id":{"type":"string"}}},"ProfileEnvelope":{"type":"object","required":["success","platform","endpoint","data","error","credits_used","credits_remaining","cached","request_id"],"description":"Identical shape on success and on error, so a client writes one parser. `success` is the only field that decides; `data` and `error` are mutually exclusive and the other is null, never absent.","properties":{"success":{"type":"boolean"},"platform":{"type":"string"},"endpoint":{"type":"string"},"data":{"type":["object","null"],"properties":{"author":{"$ref":"#/components/schemas/Author"},"items":{"type":"array","items":{"type":"object"}},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}},"error":{"type":["object","null"],"properties":{"type":{"type":"string","enum":["parametro_invalido","chave_invalida","sem_creditos","teto_estourado","nao_encontrado","bloqueado","indisponivel"],"description":"Branch on this, never on the message text."},"message":{"type":"string"},"details":{"type":["object","null"]}}},"credits_used":{"type":"integer"},"credits_remaining":{"type":["integer","null"],"description":"THE SUM: purchased balance plus what is left of this month's free allowance. This is the only number that answers \"can I call again\" — it is not 0 while you can still call. null means we could not read it, which is not the same as zero."},"credits_purchased_remaining":{"type":["integer","null"],"description":"Bought credits. These never expire."},"free_credits_remaining":{"type":["integer","null"],"description":"What is left of this month's free allowance."},"free_credits_total":{"type":["integer","null"],"description":"The monthly free allowance for this key."},"free_credits_reset_at":{"type":"string","format":"date-time","description":"When the free allowance resets: 00:00 UTC on the 1st. It is here so the sum dropping at the turn of the month is expected rather than a surprise."},"cached":{"type":"boolean","description":"`true` is a promise that we did NOT charge: it always comes with `credits_used: 0`."},"request_id":{"type":"string"}}}}},"paths":{"/api/v1/bluesky/profile":{"get":{"operationId":"blueskyProfile","summary":"Public profile on bluesky","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: public.api.bsky.app (official, no key). Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nAT Protocol handle, which is a domain name: `bsky.app`, `alguem.bsky.social`. Dots are part of it; slashes are refused.","parameters":[{"name":"handle","in":"query","required":true,"description":"AT Protocol handle, which is a domain name: `bsky.app`, `alguem.bsky.social`. Dots are part of it; slashes are refused.","schema":{"type":"string","pattern":"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$","example":"bsky.app"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/github/profile":{"get":{"operationId":"githubProfile","summary":"Public profile on github","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: api.github.com (official, authenticated). Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nGitHub username: letters, digits and single hyphens, up to 39 characters, not starting or ending with a hyphen.","parameters":[{"name":"handle","in":"query","required":true,"description":"GitHub username: letters, digits and single hyphens, up to 39 characters, not starting or ending with a hyphen.","schema":{"type":"string","pattern":"^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$","example":"torvalds"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/instagram/profile":{"get":{"operationId":"instagramProfile","summary":"Public profile on instagram","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: public profile page, logged out. Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nInstagram username: letters, digits, dot and underscore, up to 30.","parameters":[{"name":"handle","in":"query","required":true,"description":"Instagram username: letters, digits, dot and underscore, up to 30.","schema":{"type":"string","pattern":"^[A-Za-z0-9._]{1,30}$","example":"natgeo"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/linktree/profile":{"get":{"operationId":"linktreeProfile","summary":"Public profile on linktree","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: public page, logged out. Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nLinktree username: letters, digits, dot, hyphen and underscore.","parameters":[{"name":"handle","in":"query","required":true,"description":"Linktree username: letters, digits, dot, hyphen and underscore.","schema":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,60}$","example":"comedycentral"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/mastodon/profile":{"get":{"operationId":"mastodonProfile","summary":"Public profile on mastodon","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: the account's own instance (official API, no key). Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\n**`user@instance`, and the instance is required.** Mastodon is federated: `Gargron` alone identifies nobody, because a different person can hold that name on every server. A handle without an instance is refused with 400 rather than completed with a default server — completing it would return somebody else's profile with `success: true`. The instance must be a public DNS name: IP literals, ports and paths are refused. The handle comes back fully qualified, so the response value is a valid next request.","parameters":[{"name":"handle","in":"query","required":true,"description":"**`user@instance`, and the instance is required.** Mastodon is federated: `Gargron` alone identifies nobody, because a different person can hold that name on every server. A handle without an instance is refused with 400 rather than completed with a default server — completing it would return somebody else's profile with `success: true`. The instance must be a public DNS name: IP literals, ports and paths are refused. The handle comes back fully qualified, so the response value is a valid next request.","schema":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,30}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,24}$","example":"Gargron@mastodon.social"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/medium/profile":{"get":{"operationId":"mediumProfile","summary":"Public profile on medium","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: public profile page, logged out. Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nMedium username, without the `@`: letters, digits, dot, hyphen and underscore. The `@` is part of the URL, not of the name.","parameters":[{"name":"handle","in":"query","required":true,"description":"Medium username, without the `@`: letters, digits, dot, hyphen and underscore. The `@` is part of the URL, not of the name.","schema":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,64}$","example":"markmanson"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/pinterest/profile":{"get":{"operationId":"pinterestProfile","summary":"Public profile on pinterest","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: public page, logged out. Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nPinterest username: letters, digits, underscore and hyphen, up to 30.","parameters":[{"name":"handle","in":"query","required":true,"description":"Pinterest username: letters, digits, underscore and hyphen, up to 30.","schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,30}$","example":"nasa"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/soundcloud/profile":{"get":{"operationId":"soundcloudProfile","summary":"Public profile on soundcloud","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: public page, logged out. Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nSoundCloud permalink: letters, digits, hyphen and underscore. It is the last part of the profile URL, not the display name.","parameters":[{"name":"handle","in":"query","required":true,"description":"SoundCloud permalink: letters, digits, hyphen and underscore. It is the last part of the profile URL, not the display name.","schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$","example":"edsheeran"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/threads/profile":{"get":{"operationId":"threadsProfile","summary":"Public profile on threads","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: public page, logged out. Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nThreads username, same shape as Instagram: letters, digits, dot and underscore.","parameters":[{"name":"handle","in":"query","required":true,"description":"Threads username, same shape as Instagram: letters, digits, dot and underscore.","schema":{"type":"string","pattern":"^[A-Za-z0-9._]{1,30}$","example":"zuck"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/tiktok/profile":{"get":{"operationId":"tiktokProfile","summary":"Public profile on tiktok","x-credits":1,"x-charges-without-result":false,"description":"One public profile, normalised into the same envelope as every other platform. Source: public page, logged out. Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`).\n\nTikTok username: letters, digits, dot and underscore, up to 24.","parameters":[{"name":"handle","in":"query","required":true,"description":"TikTok username: letters, digits, dot and underscore, up to 24.","schema":{"type":"string","pattern":"^[A-Za-z0-9._]{1,24}$","example":"khaby.lame"}}],"responses":{"200":{"description":"Profile found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing or does not match this platform's format. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"402":{"description":"Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such profile on that platform.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"451":{"description":"`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}},"503":{"description":"`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileEnvelope"}}}}}}},"/api/v1/threads/posts":{"get":{"operationId":"threadsPosts","summary":"Recent posts on threads","x-credits":1,"x-charges-without-result":false,"description":"Posts recentes do proprio perfil no Threads, lidos do mesmo tipo de documento que threads/profile le -- mas o documento e BAIXADO DE NOVO: o cache e por endpoint+params, entao chamar as duas rotas para o mesmo handle busca duas vezes (medido 15/09/2026: 1,55s depois do profile contra 1,60s sem ele, cached:false nos dois). Devolve os posts que vieram na pagina, sem paginacao -- medido em tres handles: 4, 5 e 10. Nao aceita ?limit=. Post de terceiro citado no feed e excluido por user.username. Devolve likes, replies e reposts; quotes e sempre null (a fonte nao publica contagem de citacao). Nao devolve reshare_count (presente em 8 de 13 posts medidos) nem media_type (e inteiro e ainda nao foi decodificado).","parameters":[{"name":"handle","in":"query","required":true,"description":"Threads username, same shape as Instagram: letters, digits, dot and underscore.","schema":{"type":"string","pattern":"^[A-Za-z0-9._]{1,30}$","example":"zuck"}}],"responses":{"200":{"description":"Posts found. `has_more` is false and `next_cursor` is null on all three platforms today — there is no deep pagination.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing, does not match this platform's format, or `limit` is outside the range. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such account. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}}}}},"/api/v1/bluesky/posts":{"get":{"operationId":"blueskyPosts","summary":"Recent posts on bluesky","x-credits":1,"x-charges-without-result":false,"description":"Posts recentes do proprio perfil no Bluesky, via app.bsky.feed.getAuthorFeed -- API publica oficial do AT Protocol, sem chave e sem conta. Sai pelo servico de fetch como as demais rotas, entao consome banda de proxy: ~93 KB com limit=25. Aceita ?limit= de 1 a 100, padrao 25; valor fora da faixa e recusado com 400 em vez de aparado, e cada limite tem a sua propria linha de cache. Repost de terceiro e excluido por autoria (o feed do bsky.app traz 1 em 20). Devolve likes, replies, reposts e quotes; nao devolve bookmarkCount (so o Bluesky tem) nem thumbnail (o embed de imagem ainda nao foi medido em amostra suficiente).","parameters":[{"name":"handle","in":"query","required":true,"description":"AT Protocol handle, which is a domain name: `bsky.app`, `alguem.bsky.social`. Dots are part of it; slashes are refused.","schema":{"type":"string","pattern":"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$","example":"bsky.app"}},{"name":"limit","in":"query","required":false,"description":"How many posts to return, 1 to 100. A value outside the range is refused with 400, not clamped: the source clamps in silence, and a caller asking for more than the maximum would conclude the account has fewer posts than it does.","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"Posts found. `has_more` is false and `next_cursor` is null on all three platforms today — there is no deep pagination.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing, does not match this platform's format, or `limit` is outside the range. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such account. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}}}}},"/api/v1/mastodon/posts":{"get":{"operationId":"mastodonPosts","summary":"Recent posts on mastodon","x-credits":1,"x-charges-without-result":false,"description":"Posts recentes do proprio perfil no Mastodon, via API oficial da instancia da conta (sem chave). O handle e SEMPRE usuario@instancia: o Mastodon e federado, e o nome sozinho nao identifica ninguem. Aceita ?limit= de 1 a 40, padrao 20; valor fora da faixa e recusado com 400 em vez de aparado -- a fonte apara em silencio, e quem pedisse 100 receberia 40 achando que a conta tem 40 posts. Boost (repasse de terceiro) NAO entra: sao 15 em cada 20 no feed cru, e o objeto de cima de um boost vem com texto vazio e metricas zeradas. Resposta a outra pessoa entra, porque foi a conta que escreveu. Devolve likes, replies, reposts e quotes. O corpo vem em DOIS campos: `text` ja limpo de HTML, e `text_html` com o original -- este ultimo e HTML de terceiro e precisa ser sanitizado antes de ir para uma pagina, sob risco de XSS. `title` e sempre null (status nao tem titulo) e `position` e sempre null (ordem cronologica nao e ranking).","parameters":[{"name":"handle","in":"query","required":true,"description":"**`user@instance`, and the instance is required.** Mastodon is federated: `Gargron` alone identifies nobody, because a different person can hold that name on every server. A handle without an instance is refused with 400 rather than completed with a default server — completing it would return somebody else's profile with `success: true`. The instance must be a public DNS name: IP literals, ports and paths are refused. The handle comes back fully qualified, so the response value is a valid next request.","schema":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,30}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,24}$","example":"Gargron@mastodon.social"}},{"name":"limit","in":"query","required":false,"description":"How many posts to return, 1 to 40. A value outside the range is refused with 400, not clamped: the source clamps in silence, and a caller asking for more than the maximum would conclude the account has fewer posts than it does.","schema":{"type":"integer","minimum":1,"maximum":40,"default":20}}],"responses":{"200":{"description":"Posts found. `has_more` is false and `next_cursor` is null on all three platforms today — there is no deep pagination.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"400":{"description":"`parametro_invalido` — the handle is missing, does not match this platform's format, or `limit` is outside the range. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"401":{"description":"`chave_invalida` — key missing, unknown or inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}},"404":{"description":"`nao_encontrado` — no such account. Nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostsEnvelope"}}}}}}},"/api/v1/nichos":{"get":{"operationId":"listNiches","summary":"List available niches and how much history each has","security":[],"x-credits":0,"x-charges-without-result":true,"description":"No API key required. Returns the niche labels and archive depth, never the archive itself.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"niches":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"ai-agents"},"label":{"type":"string","example":"AI agents"},"windows_24h":{"type":"integer"},"last_window":{"type":["string","null"],"format":"date-time"}}}}}}}}}}}},"/api/v1/trends/{niche}":{"get":{"operationId":"nicheTrends","summary":"Historical series for one niche, grouped by item","x-credits":1,"x-charges-without-result":true,"description":"Grouped by ITEM, not by window. A window is how the archive stores; an item with a series is how the question is asked. Capped at 5000 series points and a 90-day span, both enforced in the database. When the cap is hit, `truncated` is true and the series is incomplete — narrow the interval rather than drawing conclusions from a partial series.\n\nAn unknown niche is NOT a 404: nothing validates the name, so a typo returns `items: []` with `points: 0` — AND still spends a call, because the quota counter increments before the archive is read. Resolve niche names at `/api/v1/nichos` first.","parameters":[{"name":"niche","in":"path","required":true,"schema":{"type":"string"},"example":"ai-agents"},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"},"description":"ISO 8601. Defaults to 7 days ago."},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"},"description":"ISO 8601. Defaults to now."},{"name":"limit","in":"query","schema":{"type":"integer"},"description":"Hint only. The hard cap lives in the database and a larger value here does not raise it."}],"responses":{"200":{"description":"OK","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"niche":{"type":"string"},"from":{"type":"string","format":"date-time"},"to":{"type":"string","format":"date-time"},"points":{"type":"integer"},"truncated":{"type":"boolean"},"credits_charged":{"type":"integer"},"paid_from":{"type":"string","enum":["free","balance"],"description":"Which pocket paid: the monthly free allowance or purchased credits."},"free_used":{"type":"integer"},"free_total":{"type":"integer"},"credits_balance":{"type":"integer"},"items":{"type":"array","items":{"$ref":"#/components/schemas/Item"}}}}}}},"400":{"description":"Malformed interval","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Monthly quota exhausted. The body carries `limit` and `renews_at`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/movers/{niche}":{"get":{"operationId":"nicheMovers","summary":"What gained traction in a niche — charged only when it finds movement","x-credits":10,"x-charges-without-result":false,"description":"Costs 10 credits, and CHARGES ONLY IF IT FINDS MOVEMENT. An empty result is a 200 with `credits_charged: 0` and a `note` saying so, not an error and not a charge: the risk of an empty window sits with us, so you do not need defensive logic to avoid calling.\n\nRANKED BY POINTS GAINED, NOT BY POSITION. This is the caveat that matters most, so it is stated before the parameters. Measured on 2026-09-06 across 19 pairs of windows: position changed for 19 of 29 items WITHOUT A SINGLE POINT CHANGING. Most items in a snapshot sit within a few points of each other, so ties are the rule, and a tie reshuffle looks exactly like movement if you read `position`. `points_gained` is Hacker News's own counter, not a number we derive, and it is what this endpoint sorts by.\n\n`interval_min` IS THE REAL ELAPSED TIME between the two captures compared, not the window you asked for. The endpoint picks the archived window whose real capture time is closest to `horas` ago — windows are not equidistant, and observed intervals have ranged from 12 to 321 minutes. Asking `horas=24` against an archive that is 20 hours deep returns `intervalo_min: 1208`, not 1440. Divide by this number, not by the one you sent.\n\nAn unknown niche is NOT a 404 here: it produces the same empty, uncharged 200 as a quiet window. Confirm the niche exists at `/api/v1/nichos` before reading an empty result as 'nothing moved'.","parameters":[{"name":"niche","in":"path","required":true,"schema":{"type":"string"},"example":"ai-agents"},{"name":"hours","in":"query","schema":{"type":"integer","minimum":1,"maximum":720,"default":24},"description":"How far back to look for the comparison window. A target, not a guarantee — the archive answers with the closest window it actually has, and reports the real gap in `interval_min`."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20},"description":"Top N by points gained. Clamped to 100 in the database; a larger value here does not raise it."}],"responses":{"200":{"description":"OK — with movement (`cobrado: 10`) or without it (`cobrado: 0`, `itens: []`, and a `note`). Both are successes; branch on `total`, not on the status code.","content":{"application/json":{"schema":{"type":"object","required":["niche","items","total","credits_charged","credits_balance"],"properties":{"niche":{"type":"string","example":"ai-agents"},"total":{"type":"integer","description":"Number of items in `items`."},"interval_min":{"type":["integer","null"],"example":1208,"description":"Real minutes between the two captures compared. `null` when there was no movement to compare. A delta without an interval is a number without a unit — read this before reading `points_gained`."},"credits_charged":{"type":"integer","enum":[0,10],"description":"Credits actually charged for this call. 0 when the result was empty."},"credits_balance":{"type":"integer","description":"Credits left on the key after this call."},"note":{"type":"string","description":"Present only on an empty, uncharged result.","example":"nothing gained traction in this window -- not charged"},"items":{"type":"array","description":"Sorted by `points_gained`, descending.","items":{"$ref":"#/components/schemas/Mover"}}}}}}},"400":{"description":"`parametro_invalido` — `horas` outside 1–720 or `limit` outside 1–100, or either one not an integer. Rejected before the call reaches the archive, so nothing is charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`chave_invalida` — missing, unknown or deactivated key. A missing `Authorization` header reports the same code as a wrong key: the database decides, and it cannot tell an absent key from one that does not exist. The response carries `WWW-Authenticate: Bearer`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"402":{"description":"Not enough credits — `sem_creditos` (body carries `credits_balance` and `needed`) or `teto_estourado` (body carries `spent_this_month` and `cap`). 402 and not 429 on purpose: 429 means wait and retry, 402 means waiting will not help. Do not back off — top up or raise the ceiling.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`endpoint_indisponivel` — this endpoint is switched off at the database, not broken. Nothing charged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}}}}